Commit aa6d5bfab8
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +2 −2
| @@ -402,7 +402,7 @@ func issueCmd() *cobra.Command { | |||
| 402 | return group("issue", "issues", | 402 | return group("issue", "issues", |
| 403 | pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]", | 403 | pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]", |
| 404 | passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), | 404 | passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), |
| 405 | pass("list", "list issues [--state open|closed|all] [--limit n] [--cursor c]", passOpts{server: []string{"issue", "list"}, needsRepo: true}), | 405 | pass("list", "list issues [--state open|closed|all] [--label l] [--assignee u] [--author u] [--milestone m|none] [--limit n] [--cursor c]", passOpts{server: []string{"issue", "list"}, needsRepo: true}), |
| 406 | pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}), | 406 | pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}), |
| 407 | pass("comment", "comment on an issue [--message|--file -|$EDITOR]", | 407 | pass("comment", "comment on an issue [--message|--file -|$EDITOR]", |
| 408 | passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), | 408 | passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| @@ -448,7 +448,7 @@ func mrCmd() *cobra.Command { | |||
| 448 | return group("mr", "merge requests", | 448 | return group("mr", "merge requests", |
| 449 | pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", | 449 | pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", |
| 450 | passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}), | 450 | passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}), |
| 451 | pass("list", "list merge requests [--state ...] [--limit n] [--cursor c]", passOpts{server: []string{"mr", "list"}, needsRepo: true}), | 451 | pass("list", "list merge requests [--state ...] [--author u] [--milestone m|none] [--limit n] [--cursor c]", passOpts{server: []string{"mr", "list"}, needsRepo: true}), |
| 452 | pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), | 452 | pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), |
| 453 | pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), | 453 | pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), |
| 454 | local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), | 454 | local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), |
e2e/listfilters_test.go added +121
| @@ -0,0 +1,121 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // issue list and mr list narrow by label, assignee, author and | ||
| 11 | // milestone, and the web lists take the same names as query parameters. | ||
| 12 | func TestListFilters(t *testing.T) { | ||
| 13 | inst := startInstance(t) | ||
| 14 | aliceKey := inst.newKey(t, "alice") | ||
| 15 | bobKey := inst.newKey(t, "bob") | ||
| 16 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 18 | must := func(key string, args ...string) { | ||
| 19 | t.Helper() | ||
| 20 | if _, errOut, code := inst.ssh(t, key, "", args...); code != 0 { | ||
| 21 | t.Fatalf("%v: %s", args, errOut) | ||
| 22 | } | ||
| 23 | } | ||
| 24 | must(aliceKey, "repo", "create", "alice/app") | ||
| 25 | must(aliceKey, "repo", "access", "grant", "alice/app", "bob", "write") | ||
| 26 | must(aliceKey, "milestone", "create", "alice/app", "v1") | ||
| 27 | must(aliceKey, "issue", "create", "alice/app", "--title", "one") // #1 alice, bug, assigned bob, v1 | ||
| 28 | must(bobKey, "issue", "create", "alice/app", "--title", "two") // #2 bob, docs | ||
| 29 | must(aliceKey, "issue", "create", "alice/app", "--title", "three") // #3 alice, closed, bug | ||
| 30 | must(aliceKey, "issue", "label", "alice/app", "1", "--add", "bug") | ||
| 31 | must(aliceKey, "issue", "label", "alice/app", "3", "--add", "bug") | ||
| 32 | must(aliceKey, "issue", "label", "alice/app", "2", "--add", "docs") | ||
| 33 | must(aliceKey, "issue", "assign", "alice/app", "1", "--add", "bob") | ||
| 34 | must(aliceKey, "issue", "milestone", "alice/app", "1", "v1") | ||
| 35 | must(aliceKey, "issue", "close", "alice/app", "3") | ||
| 36 | |||
| 37 | list := func(args ...string) string { | ||
| 38 | t.Helper() | ||
| 39 | out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"issue", "list", "alice/app"}, args...)...) | ||
| 40 | if code != 0 { | ||
| 41 | t.Fatalf("issue list %v: %s", args, errOut) | ||
| 42 | } | ||
| 43 | return out | ||
| 44 | } | ||
| 45 | nums := func(out string) string { | ||
| 46 | var n []string | ||
| 47 | for _, line := range strings.Split(strings.TrimSpace(out), "\n") { | ||
| 48 | if line != "" { | ||
| 49 | n = append(n, strings.Fields(line)[0]) | ||
| 50 | } | ||
| 51 | } | ||
| 52 | return strings.Join(n, " ") | ||
| 53 | } | ||
| 54 | cases := []struct{ args, want string }{ | ||
| 55 | {"--label bug", "#1"}, | ||
| 56 | {"--label bug --state all", "#3 #1"}, | ||
| 57 | {"--label docs", "#2"}, | ||
| 58 | {"--assignee bob", "#1"}, | ||
| 59 | {"--author bob", "#2"}, | ||
| 60 | {"--author alice --state all", "#3 #1"}, | ||
| 61 | {"--milestone v1", "#1"}, | ||
| 62 | {"--milestone none", "#2"}, | ||
| 63 | {"--label bug --milestone none", ""}, | ||
| 64 | } | ||
| 65 | for _, c := range cases { | ||
| 66 | if got := nums(list(strings.Fields(c.args)...)); got != c.want { | ||
| 67 | t.Errorf("issue list %s: got %q, want %q", c.args, got, c.want) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--label"); code != 2 { | ||
| 71 | t.Fatal("dangling --label accepted") | ||
| 72 | } | ||
| 73 | |||
| 74 | // Merge requests: author and milestone. | ||
| 75 | must(aliceKey, "repo", "create", "alice/lib") | ||
| 76 | env := inst.gitEnv(aliceKey) | ||
| 77 | work := t.TempDir() | ||
| 78 | mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w") | ||
| 79 | dir := filepath.Join(work, "w") | ||
| 80 | os.WriteFile(filepath.Join(dir, "base.txt"), []byte("base\n"), 0o644) | ||
| 81 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 82 | mustGit(t, dir, env, "add", ".") | ||
| 83 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 84 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 85 | for _, b := range []string{"f1", "f2"} { | ||
| 86 | mustGit(t, dir, env, "checkout", "-q", "-b", b, "main") | ||
| 87 | os.WriteFile(filepath.Join(dir, b+".txt"), []byte(b+"\n"), 0o644) | ||
| 88 | mustGit(t, dir, env, "add", ".") | ||
| 89 | mustGit(t, dir, env, "commit", "-q", "-m", b) | ||
| 90 | mustGit(t, dir, env, "push", "-q", "origin", b) | ||
| 91 | } | ||
| 92 | must(aliceKey, "mr", "create", "alice/lib", "--source", "f1", "--target", "main", "--title", "a") | ||
| 93 | must(aliceKey, "repo", "access", "grant", "alice/lib", "bob", "write") | ||
| 94 | must(bobKey, "mr", "create", "alice/lib", "--source", "f2", "--target", "main", "--title", "b") | ||
| 95 | must(aliceKey, "milestone", "create", "alice/lib", "v1") | ||
| 96 | must(aliceKey, "mr", "milestone", "alice/lib", "2", "v1") | ||
| 97 | mrl := func(args ...string) string { | ||
| 98 | out, _, _ := inst.ssh(t, aliceKey, "", append([]string{"mr", "list", "alice/lib"}, args...)...) | ||
| 99 | return nums(out) | ||
| 100 | } | ||
| 101 | if got := mrl("--author", "bob"); got != "!2" { | ||
| 102 | t.Errorf("mr list --author bob: %q", got) | ||
| 103 | } | ||
| 104 | if got := mrl("--milestone", "v1"); got != "!2" { | ||
| 105 | t.Errorf("mr list --milestone v1: %q", got) | ||
| 106 | } | ||
| 107 | if got := mrl("--milestone", "none"); got != "!1" { | ||
| 108 | t.Errorf("mr list --milestone none: %q", got) | ||
| 109 | } | ||
| 110 | |||
| 111 | // The web takes the same names. | ||
| 112 | if _, body := inst.get(t, "/alice/app/issues?assignee=bob&state=all"); !strings.Contains(body, ">one<") || strings.Contains(body, ">two<") || !strings.Contains(body, "assignee: <b>bob</b>") { | ||
| 113 | t.Fatalf("web assignee filter:\n%s", body) | ||
| 114 | } | ||
| 115 | if _, body := inst.get(t, "/alice/app/issues?label=bug&state=all&author=alice"); !strings.Contains(body, ">three<") || strings.Contains(body, ">two<") || !strings.Contains(body, `href="?author=alice&state=all"`) { | ||
| 116 | t.Fatalf("web label+author filter with clear link:\n%s", body) | ||
| 117 | } | ||
| 118 | if _, body := inst.get(t, "/alice/lib/mrs?author=bob"); !strings.Contains(body, ">b<") || strings.Contains(body, ">a<") { | ||
| 119 | t.Fatalf("web mr author filter:\n%s", body) | ||
| 120 | } | ||
| 121 | } | ||
internal/control/issue.go +25 −12
| @@ -21,7 +21,7 @@ func init() { | |||
| 21 | ReadsStdin: true, Run: runIssueCreate}) | 21 | ReadsStdin: true, Run: runIssueCreate}) |
| 22 | register(Command{Path: []string{"issue", "list"}, | 22 | register(Command{Path: []string{"issue", "list"}, |
| 23 | Summary: "list issues", | 23 | Summary: "list issues", |
| 24 | Usage: "issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList}) | 24 | Usage: "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList}) |
| 25 | register(Command{Path: []string{"issue", "show"}, | 25 | register(Command{Path: []string{"issue", "show"}, |
| 26 | Summary: "show an issue with comments", | 26 | Summary: "show an issue with comments", |
| 27 | Usage: "issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) | 27 | Usage: "issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) |
| @@ -198,31 +198,44 @@ func runIssueList(c *Ctx, args []string) int { | |||
| 198 | if code >= 0 { | 198 | if code >= 0 { |
| 199 | return code | 199 | return code |
| 200 | } | 200 | } |
| 201 | state := "open" | 201 | const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]" |
| 202 | f := store.IssueFilter{State: "open"} | ||
| 202 | var path string | 203 | var path string |
| 203 | for i := 0; i < len(args); i++ { | 204 | for i := 0; i < len(args); i++ { |
| 205 | var target *string | ||
| 204 | switch args[i] { | 206 | switch args[i] { |
| 205 | case "--state": | 207 | case "--state": |
| 206 | if i+1 >= len(args) { | 208 | target = &f.State |
| 207 | return c.fail(protocol.ExitUsage, "--state requires open|closed|all") | 209 | case "--label": |
| 208 | } | 210 | target = &f.Label |
| 209 | state = args[i+1] | 211 | case "--assignee": |
| 210 | i++ | 212 | target = &f.Assignee |
| 213 | case "--author": | ||
| 214 | target = &f.Author | ||
| 215 | case "--milestone": | ||
| 216 | target = &f.Milestone | ||
| 211 | default: | 217 | default: |
| 212 | if path != "" { | 218 | if path != "" || strings.HasPrefix(args[i], "--") { |
| 213 | return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) | 219 | return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage) |
| 214 | } | 220 | } |
| 215 | path = args[i] | 221 | path = args[i] |
| 222 | continue | ||
| 223 | } | ||
| 224 | if i+1 >= len(args) { | ||
| 225 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | ||
| 216 | } | 226 | } |
| 227 | *target = args[i+1] | ||
| 228 | i++ | ||
| 217 | } | 229 | } |
| 218 | if path == "" || (state != "open" && state != "closed" && state != "all") { | 230 | if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") { |
| 219 | return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]") | 231 | return c.fail(protocol.ExitUsage, usage) |
| 220 | } | 232 | } |
| 221 | repo, code := resolveRepo(c, path, policy.CanRead) | 233 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 222 | if code >= 0 { | 234 | if code >= 0 { |
| 223 | return code | 235 | return code |
| 224 | } | 236 | } |
| 225 | issues, err := c.Store.ListIssues(repo.ID, state, p.queryLimit(), p.keyInt()) | 237 | f.Limit, f.Before = p.queryLimit(), p.keyInt() |
| 238 | issues, err := c.Store.QueryIssues(repo.ID, f) | ||
| 226 | if err != nil { | 239 | if err != nil { |
| 227 | return c.fail(protocol.ExitFailure, "%v", err) | 240 | return c.fail(protocol.ExitFailure, "%v", err) |
| 228 | } | 241 | } |
internal/control/mr.go +21 −12
| @@ -38,7 +38,7 @@ func init() { | |||
| 38 | ReadsStdin: true, Run: runMRCreate}) | 38 | ReadsStdin: true, Run: runMRCreate}) |
| 39 | register(Command{Path: []string{"mr", "list"}, | 39 | register(Command{Path: []string{"mr", "list"}, |
| 40 | Summary: "list merge requests", | 40 | Summary: "list merge requests", |
| 41 | Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList}) | 41 | Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList}) |
| 42 | register(Command{Path: []string{"mr", "show"}, | 42 | register(Command{Path: []string{"mr", "show"}, |
| 43 | Summary: "show a merge request", | 43 | Summary: "show a merge request", |
| 44 | Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) | 44 | Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) |
| @@ -410,32 +410,41 @@ func runMRList(c *Ctx, args []string) int { | |||
| 410 | if code >= 0 { | 410 | if code >= 0 { |
| 411 | return code | 411 | return code |
| 412 | } | 412 | } |
| 413 | state := "open" | 413 | const usage = "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]" |
| 414 | f := store.MRFilter{State: "open"} | ||
| 414 | var path string | 415 | var path string |
| 415 | for i := 0; i < len(args); i++ { | 416 | for i := 0; i < len(args); i++ { |
| 417 | var target *string | ||
| 416 | switch args[i] { | 418 | switch args[i] { |
| 417 | case "--state": | 419 | case "--state": |
| 418 | if i+1 >= len(args) { | 420 | target = &f.State |
| 419 | return c.fail(protocol.ExitUsage, "--state requires a value") | 421 | case "--author": |
| 420 | } | 422 | target = &f.Author |
| 421 | state = args[i+1] | 423 | case "--milestone": |
| 422 | i++ | 424 | target = &f.Milestone |
| 423 | default: | 425 | default: |
| 424 | if path != "" { | 426 | if path != "" || strings.HasPrefix(args[i], "--") { |
| 425 | return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) | 427 | return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage) |
| 426 | } | 428 | } |
| 427 | path = args[i] | 429 | path = args[i] |
| 430 | continue | ||
| 431 | } | ||
| 432 | if i+1 >= len(args) { | ||
| 433 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | ||
| 428 | } | 434 | } |
| 435 | *target = args[i+1] | ||
| 436 | i++ | ||
| 429 | } | 437 | } |
| 430 | valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} | 438 | valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} |
| 431 | if path == "" || !valid[state] { | 439 | if path == "" || !valid[f.State] { |
| 432 | return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]") | 440 | return c.fail(protocol.ExitUsage, usage) |
| 433 | } | 441 | } |
| 434 | repo, code := resolveRepo(c, path, policy.CanRead) | 442 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 435 | if code >= 0 { | 443 | if code >= 0 { |
| 436 | return code | 444 | return code |
| 437 | } | 445 | } |
| 438 | mrs, err := c.Store.ListMRs(repo.ID, state, p.queryLimit(), p.keyInt()) | 446 | f.Limit, f.Before = p.queryLimit(), p.keyInt() |
| 447 | mrs, err := c.Store.QueryMRs(repo.ID, f) | ||
| 439 | if err != nil { | 448 | if err != nil { |
| 440 | return c.fail(protocol.ExitFailure, "%v", err) | 449 | return c.fail(protocol.ExitFailure, "%v", err) |
| 441 | } | 450 | } |
internal/httpd/filters.go added +30
| @@ -0,0 +1,30 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import "net/url" | ||
| 4 | |||
| 5 | // listFilter is one active narrowing on a list page, with the URL that | ||
| 6 | // drops it and keeps the rest. | ||
| 7 | type listFilter struct { | ||
| 8 | Key string | ||
| 9 | Value string | ||
| 10 | Clear string | ||
| 11 | } | ||
| 12 | |||
| 13 | // activeFilters turns the non-empty (key, value) pairs into the chips a | ||
| 14 | // list page shows above its rows. | ||
| 15 | func activeFilters(state string, pairs [][2]string) []listFilter { | ||
| 16 | var out []listFilter | ||
| 17 | for i, kv := range pairs { | ||
| 18 | if kv[1] == "" { | ||
| 19 | continue | ||
| 20 | } | ||
| 21 | q := url.Values{"state": {state}} | ||
| 22 | for j, other := range pairs { | ||
| 23 | if j != i && other[1] != "" { | ||
| 24 | q.Set(other[0], other[1]) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | out = append(out, listFilter{Key: kv[0], Value: kv[1], Clear: "?" + q.Encode()}) | ||
| 28 | } | ||
| 29 | return out | ||
| 30 | } | ||
internal/httpd/web.go +16 −20
| @@ -1487,7 +1487,12 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | |||
| 1487 | if state != "closed" && state != "all" { | 1487 | if state != "closed" && state != "all" { |
| 1488 | state = "open" | 1488 | state = "open" |
| 1489 | } | 1489 | } |
| 1490 | issues, err := s.st.ListIssues(p.Repo.ID, state, 0, 0) | 1490 | // The same filters the CLI's issue list takes, as query parameters; |
| 1491 | // label chips and author links point here. | ||
| 1492 | qv := r.URL.Query() | ||
| 1493 | f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"), | ||
| 1494 | Author: qv.Get("author"), Milestone: qv.Get("milestone")} | ||
| 1495 | issues, err := s.st.QueryIssues(p.Repo.ID, f) | ||
| 1491 | if err != nil { | 1496 | if err != nil { |
| 1492 | http.Error(w, "internal error", http.StatusInternalServerError) | 1497 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 1493 | return | 1498 | return |
| @@ -1497,27 +1502,15 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | |||
| 1497 | issues[i].Labels = labels[issues[i].ID] | 1502 | issues[i].Labels = labels[issues[i].ID] |
| 1498 | } | 1503 | } |
| 1499 | } | 1504 | } |
| 1500 | // ?label=x narrows to issues carrying that label (chips link here). | ||
| 1501 | labelFilter := r.URL.Query().Get("label") | ||
| 1502 | if labelFilter != "" { | ||
| 1503 | var kept []store.Issue | ||
| 1504 | for _, iss := range issues { | ||
| 1505 | for _, l := range iss.Labels { | ||
| 1506 | if l == labelFilter { | ||
| 1507 | kept = append(kept, iss) | ||
| 1508 | break | ||
| 1509 | } | ||
| 1510 | } | ||
| 1511 | } | ||
| 1512 | issues = kept | ||
| 1513 | } | ||
| 1514 | s.render(w, "issues.html", struct { | 1505 | s.render(w, "issues.html", struct { |
| 1515 | repoPage | 1506 | repoPage |
| 1516 | State string | 1507 | State string |
| 1517 | Label string | 1508 | Label string |
| 1509 | Filters []listFilter | ||
| 1518 | Issues []store.Issue | 1510 | Issues []store.Issue |
| 1519 | LabelColors map[string]template.CSS | 1511 | LabelColors map[string]template.CSS |
| 1520 | }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)}) | 1512 | }{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}), |
| 1513 | issues, s.labelColors(p.Repo.ID)}) | ||
| 1521 | } | 1514 | } |
| 1522 | 1515 | ||
| 1523 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | 1516 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { |
| @@ -1602,16 +1595,19 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | |||
| 1602 | if !valid[state] { | 1595 | if !valid[state] { |
| 1603 | state = "open" | 1596 | state = "open" |
| 1604 | } | 1597 | } |
| 1605 | mrs, err := s.st.ListMRs(p.Repo.ID, state, 0, 0) | 1598 | qv := r.URL.Query() |
| 1599 | mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone")} | ||
| 1600 | mrs, err := s.st.QueryMRs(p.Repo.ID, mf) | ||
| 1606 | if err != nil { | 1601 | if err != nil { |
| 1607 | http.Error(w, "internal error", http.StatusInternalServerError) | 1602 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 1608 | return | 1603 | return |
| 1609 | } | 1604 | } |
| 1610 | s.render(w, "mrs.html", struct { | 1605 | s.render(w, "mrs.html", struct { |
| 1611 | repoPage | 1606 | repoPage |
| 1612 | State string | 1607 | State string |
| 1613 | MRs []store.MR | 1608 | Filters []listFilter |
| 1614 | }{p, state, mrs}) | 1609 | MRs []store.MR |
| 1610 | }{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs}) | ||
| 1615 | } | 1611 | } |
| 1616 | 1612 | ||
| 1617 | func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | 1613 | func (s *Server) mr(w http.ResponseWriter, r *http.Request) { |
internal/store/issues.go +46 −7
| @@ -100,25 +100,64 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) { | |||
| 100 | // ListIssues returns issues for a repo; state is "open", "closed", or | 100 | // ListIssues returns issues for a repo; state is "open", "closed", or |
| 101 | // "all". limit 0 means everything; before (an issue number) starts the | 101 | // "all". limit 0 means everything; before (an issue number) starts the |
| 102 | // page strictly below it, matching the number-descending order. | 102 | // page strictly below it, matching the number-descending order. |
| 103 | // IssueFilter narrows a listing. Empty strings match anything; State | ||
| 104 | // "all" too. Milestone "none" selects issues with no milestone. | ||
| 105 | type IssueFilter struct { | ||
| 106 | State string | ||
| 107 | Label string | ||
| 108 | Assignee string | ||
| 109 | Author string | ||
| 110 | Milestone string | ||
| 111 | Limit int | ||
| 112 | Before int64 | ||
| 113 | } | ||
| 114 | |||
| 103 | func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { | 115 | func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { |
| 116 | return s.QueryIssues(repoID, IssueFilter{State: state, Limit: limit, Before: before}) | ||
| 117 | } | ||
| 118 | |||
| 119 | // QueryIssues lists a repository's issues, newest first, narrowed by f. | ||
| 120 | func (s *Store) QueryIssues(repoID int64, f IssueFilter) ([]Issue, error) { | ||
| 104 | q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state, | 121 | q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state, |
| 105 | COALESCE(m.title, ''), i.created_at, i.updated_at | 122 | COALESCE(m.title, ''), i.created_at, i.updated_at |
| 106 | FROM issues i JOIN users u ON u.id = i.author_id | 123 | FROM issues i JOIN users u ON u.id = i.author_id |
| 107 | LEFT JOIN milestones m ON m.id = i.milestone_id | 124 | LEFT JOIN milestones m ON m.id = i.milestone_id |
| 108 | WHERE i.repo_id = ?` | 125 | WHERE i.repo_id = ?` |
| 109 | args := []any{repoID} | 126 | args := []any{repoID} |
| 110 | if state != "all" { | 127 | if f.State != "" && f.State != "all" { |
| 111 | q += " AND i.state = ?" | 128 | q += " AND i.state = ?" |
| 112 | args = append(args, state) | 129 | args = append(args, f.State) |
| 113 | } | 130 | } |
| 114 | if before > 0 { | 131 | if f.Label != "" { |
| 132 | q += ` AND EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id | ||
| 133 | WHERE il.issue_id = i.id AND l.name = ?)` | ||
| 134 | args = append(args, f.Label) | ||
| 135 | } | ||
| 136 | if f.Assignee != "" { | ||
| 137 | q += ` AND EXISTS (SELECT 1 FROM issue_assignees ia JOIN users au ON au.id = ia.user_id | ||
| 138 | WHERE ia.issue_id = i.id AND au.username = ?)` | ||
| 139 | args = append(args, f.Assignee) | ||
| 140 | } | ||
| 141 | if f.Author != "" { | ||
| 142 | q += " AND u.username = ?" | ||
| 143 | args = append(args, f.Author) | ||
| 144 | } | ||
| 145 | switch f.Milestone { | ||
| 146 | case "": | ||
| 147 | case "none": | ||
| 148 | q += " AND i.milestone_id IS NULL" | ||
| 149 | default: | ||
| 150 | q += " AND m.title = ?" | ||
| 151 | args = append(args, f.Milestone) | ||
| 152 | } | ||
| 153 | if f.Before > 0 { | ||
| 115 | q += " AND i.number < ?" | 154 | q += " AND i.number < ?" |
| 116 | args = append(args, before) | 155 | args = append(args, f.Before) |
| 117 | } | 156 | } |
| 118 | q += " ORDER BY i.number DESC" | 157 | q += " ORDER BY i.number DESC" |
| 119 | if limit > 0 { | 158 | if f.Limit > 0 { |
| 120 | q += " LIMIT ?" | 159 | q += " LIMIT ?" |
| 121 | args = append(args, limit) | 160 | args = append(args, f.Limit) |
| 122 | } | 161 | } |
| 123 | rows, err := s.DB.Query(q, args...) | 162 | rows, err := s.DB.Query(q, args...) |
| 124 | if err != nil { | 163 | if err != nil { |
internal/store/mrs.go +34 −6
| @@ -96,21 +96,49 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) { | |||
| 96 | // ListMRs returns merge requests for a repo. limit 0 means everything; | 96 | // ListMRs returns merge requests for a repo. limit 0 means everything; |
| 97 | // before (an MR number) starts the page strictly below it, matching the | 97 | // before (an MR number) starts the page strictly below it, matching the |
| 98 | // number-descending order. | 98 | // number-descending order. |
| 99 | // MRFilter narrows a listing. Empty strings match anything; State "all" | ||
| 100 | // too. Milestone "none" selects merge requests with no milestone. | ||
| 101 | type MRFilter struct { | ||
| 102 | State string | ||
| 103 | Author string | ||
| 104 | Milestone string | ||
| 105 | Limit int | ||
| 106 | Before int64 | ||
| 107 | } | ||
| 108 | |||
| 99 | func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) { | 109 | func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) { |
| 110 | return s.QueryMRs(repoID, MRFilter{State: state, Limit: limit, Before: before}) | ||
| 111 | } | ||
| 112 | |||
| 113 | // QueryMRs lists a repository's merge requests, newest first, narrowed | ||
| 114 | // by f. | ||
| 115 | func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) { | ||
| 100 | q := mrSelect + " WHERE m.repo_id = ?" | 116 | q := mrSelect + " WHERE m.repo_id = ?" |
| 101 | args := []any{repoID} | 117 | args := []any{repoID} |
| 102 | if state != "all" { | 118 | if f.State != "" && f.State != "all" { |
| 103 | q += " AND m.state = ?" | 119 | q += " AND m.state = ?" |
| 104 | args = append(args, state) | 120 | args = append(args, f.State) |
| 121 | } | ||
| 122 | if f.Author != "" { | ||
| 123 | q += " AND u.username = ?" | ||
| 124 | args = append(args, f.Author) | ||
| 125 | } | ||
| 126 | switch f.Milestone { | ||
| 127 | case "": | ||
| 128 | case "none": | ||
| 129 | q += " AND m.milestone_id IS NULL" | ||
| 130 | default: | ||
| 131 | q += " AND ms.title = ?" | ||
| 132 | args = append(args, f.Milestone) | ||
| 105 | } | 133 | } |
| 106 | if before > 0 { | 134 | if f.Before > 0 { |
| 107 | q += " AND m.number < ?" | 135 | q += " AND m.number < ?" |
| 108 | args = append(args, before) | 136 | args = append(args, f.Before) |
| 109 | } | 137 | } |
| 110 | q += " ORDER BY m.number DESC" | 138 | q += " ORDER BY m.number DESC" |
| 111 | if limit > 0 { | 139 | if f.Limit > 0 { |
| 112 | q += " LIMIT ?" | 140 | q += " LIMIT ?" |
| 113 | args = append(args, limit) | 141 | args = append(args, f.Limit) |
| 114 | } | 142 | } |
| 115 | rows, err := s.DB.Query(q, args...) | 143 | rows, err := s.DB.Query(q, args...) |
| 116 | if err != nil { | 144 | if err != nil { |
internal/web/templates/issues.html +1 −1
| @@ -7,7 +7,7 @@ | |||
| 7 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> | 7 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> |
| 8 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> | 8 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> |
| 9 | </nav> | 9 | </nav> |
| 10 | {{if .Label}}<p class="meta">label: <span class="chip label" style="{{index .LabelColors .Label}}">{{.Label}}</span> <a href="?state={{.State}}">clear</a></p>{{end}} | 10 | {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} |
| 11 | <span class="spacer"></span> | 11 | <span class="spacer"></span> |
| 12 | <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p> | 12 | <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p> |
| 13 | </div> | 13 | </div> |
internal/web/templates/mrs.html +1
| @@ -8,6 +8,7 @@ | |||
| 8 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> | 8 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> |
| 9 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> | 9 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> |
| 10 | </nav> | 10 | </nav> |
| 11 | {{range .Filters}}<p class="meta">{{.Key}}: <b>{{.Value}}</b> <a href="{{.Clear}}">clear</a></p>{{end}} | ||
| 11 | </div> | 12 | </div> |
| 12 | {{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} | 13 | {{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} |
| 13 | <ul class="issuelist"> | 14 | <ul class="issuelist"> |