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 | 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 | 59 | pass("feed", passOpts{server: []string{"feed"}}), |
| 60 | 60 | withShort(pass("explore", passOpts{server: []string{"explore"}}), "public repositories on this instance"), |
| 61 | 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 | 71 | group("notifications", "your notification inbox", |
| 63 | 72 | pass("list", passOpts{server: []string{"notifications", "list"}}), |
| 64 | 73 | pass("read", passOpts{server: []string{"notifications", "read"}}), |
| @@ -159,7 +168,7 @@ type rootSection struct { | ||
| 159 | 168 | } |
| 160 | 169 | |
| 161 | 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 | 172 | {"REPOSITORIES", []string{"repo", "wiki", "status", "webhook", "init"}}, |
| 164 | 173 | {"YOU", []string{"dashboard", "feed", "notifications", "auth", "profile", "snippet", "web"}}, |
| 165 | 174 | {"INSTANCE", []string{"org", "explore", "register", "migrate", "remote", "admin", "audit", "man"}}, |
| @@ -213,6 +222,7 @@ const stdinWhat = "gitbay.stdin_what" | ||
| 213 | 222 | type passOpts struct { |
| 214 | 223 | server []string // server-side command path |
| 215 | 224 | needsRepo bool // prepend inferred owner/name unless given |
| 225 | crossRepo []string // flags that span repositories: with one, no repository is inferred | |
| 216 | 226 | stdinOK bool // wire local stdin through when --file - asks for it |
| 217 | 227 | alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect |
| 218 | 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 | 304 | return protocol.ExitFailure |
| 295 | 305 | } |
| 296 | 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 | 308 | args, err = withRepo(t, args) |
| 299 | 309 | if err != nil { |
| 300 | 310 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| @@ -632,7 +642,7 @@ func repoCmd() *cobra.Command { | ||
| 632 | 642 | func issueCmd() *cobra.Command { |
| 633 | 643 | return group("issue", "issues", |
| 634 | 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 | 646 | pass("show", passOpts{server: []string{"issue", "show"}, needsRepo: true}), |
| 637 | 647 | pass("comment", passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 638 | 648 | pass("close", passOpts{server: []string{"issue", "close"}, needsRepo: true}), |
| @@ -675,7 +685,7 @@ func mrCmd() *cobra.Command { | ||
| 675 | 685 | review.AddCommand(pass("request", passOpts{server: []string{"mr", "review", "request"}, needsRepo: true})) |
| 676 | 686 | return group("mr", "merge requests", |
| 677 | 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 | 689 | pass("show", passOpts{server: []string{"mr", "show"}, needsRepo: true}), |
| 680 | 690 | pass("diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), |
| 681 | 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 | 127 | "pgp remove": "remove an OpenPGP key by fingerprint", |
| 128 | 128 | "profile set": "set your profile", |
| 129 | 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 | 137 | "register": "create an account (only meaningful for unregistered keys)", |
| 131 | 138 | "release asset add": "upload an asset from stdin", |
| 132 | 139 | "release asset get": "write an asset to stdout", |
internal/control/dashboard.go +16
| @@ -82,6 +82,8 @@ type DashboardOut struct { | ||
| 82 | 82 | Pinned []PinnedOut `json:"pinned"` |
| 83 | 83 | Activity []FeedOut `json:"recent_activity"` |
| 84 | 84 | Builds []DashboardBuild `json:"builds"` |
| 85 | // Queries is each pinned saved query with its first rows. | |
| 86 | Queries []DashboardQuery `json:"queries"` | |
| 85 | 87 | // Unread is the notification inbox badge, so a client showing one |
| 86 | 88 | // does not need a second read to fill it. |
| 87 | 89 | Unread int `json:"unread"` |
| @@ -161,6 +163,9 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 161 | 163 | for _, b := range builds { |
| 162 | 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 | 169 | d.Unread = c.Store.UnreadNotices(c.User.ID) |
| 165 | 170 | if c.User.IsAdmin { |
| 166 | 171 | d.Server = &ServerOut{Commit: buildinfo.String()} |
| @@ -217,6 +222,17 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 217 | 222 | section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) |
| 218 | 223 | section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) |
| 219 | 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 | 237 | pinnedRows := make([][]cell, len(d.Pinned)) |
| 222 | 238 | for i, p := range d.Pinned { |
internal/control/help.go +1
| @@ -49,6 +49,7 @@ var nounSummaries = map[string]string{ | ||
| 49 | 49 | "repo": "create and manage repositories", |
| 50 | 50 | "runner": "the claim/report loop CI runners use", |
| 51 | 51 | "search": "find repositories, issues and merge requests", |
| 52 | "query": "saved issue and merge request queries across repositories", | |
| 52 | 53 | "snippet": "shared text files, outside any repository", |
| 53 | 54 | "status": "commit statuses (CI)", |
| 54 | 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 | 35 | ReadsStdin: true, Run: runIssueCreate}) |
| 36 | 36 | register(Command{Path: []string{"issue", "list"}, |
| 37 | 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 | 39 | Flags: []Flag{ |
| 40 | 40 | {"--state", "open|closed|all", "which issues", "open"}, |
| 41 | 41 | {"--label", "<l>", "only issues carrying this label", ""}, |
| @@ -43,12 +43,16 @@ func init() { | ||
| 43 | 43 | {"--author", "<user>", "only issues opened by this user", ""}, |
| 44 | 44 | {"--milestone", "<title>|none", "only issues in this milestone, or in none", ""}, |
| 45 | 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 | 48 | {"--limit", "<n>", "rows per page", ""}, |
| 47 | 49 | {"--cursor", "<c>", "continue from the previous page", ""}, |
| 48 | 50 | }, |
| 49 | 51 | Examples: []string{ |
| 50 | 52 | "issue list krz/gitbay --label bug --state all", |
| 51 | 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 | 57 | ReadOnly: true, Run: runIssueList}) |
| 54 | 58 | register(Command{Path: []string{"issue", "show"}, |
| @@ -279,6 +283,17 @@ func runIssueCreate(c *Ctx, args []string) int { | ||
| 279 | 283 | } |
| 280 | 284 | |
| 281 | 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 | 297 | args, p, code := parsePageFlags(c, args, "issue", true) |
| 283 | 298 | if code >= 0 { |
| 284 | 299 | return code |
internal/control/mr.go +16 −1
| @@ -105,19 +105,23 @@ func init() { | ||
| 105 | 105 | Run: runMRReady}) |
| 106 | 106 | register(Command{Path: []string{"mr", "list"}, |
| 107 | 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 | 109 | Flags: []Flag{ |
| 110 | 110 | {"--state", "open|merged|closed|source_gone|all", "which merge requests", "open"}, |
| 111 | 111 | {"--label", "<l>", "only MRs carrying this label", ""}, |
| 112 | 112 | {"--author", "<user>", "only MRs opened by this user", ""}, |
| 113 | 113 | {"--milestone", "<title>|none", "only MRs in this milestone, or in none", ""}, |
| 114 | 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 | 117 | {"--limit", "<n>", "rows per page", ""}, |
| 116 | 118 | {"--cursor", "<c>", "continue from the previous page", ""}, |
| 117 | 119 | }, |
| 118 | 120 | Examples: []string{ |
| 119 | 121 | "mr list krz/gitbay --state open", |
| 120 | 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 | 126 | ReadOnly: true, Run: runMRList}) |
| 123 | 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 | 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 | 637 | args, p, code := parsePageFlags(c, args, "mr", true) |
| 623 | 638 | if code >= 0 { |
| 624 | 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 | } | |