Commit aa6d5bfab8

aa6d5bfab86d3163d485be9d89b313c2b0546009

parent: 1733abfe3d

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 00:16 UTC

issue list and mr list: filter by label, assignee, author, milestone

Both lists took --state and nothing else, so the web's list filtering
could never match the CLI's; the issues page narrowed by label after
the fact, in memory.

issue list takes --label, --assignee, --author and --milestone (a title,
or none for issues with no milestone); mr list takes --author and
--milestone. The store does the narrowing in SQL, in QueryIssues and
QueryMRs, and the web lists take the same names as query parameters and
show each active filter with a link that drops it and keeps the rest.

Closes #84

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 @@
1package e2e
2
3import (
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.
12func 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&amp;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 @@
1package httpd
2
3import "net/url"
4
5// listFilter is one active narrowing on a list page, with the URL that
6// drops it and keeps the rest.
7type 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.
15func 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
1523func (s *Server) issue(w http.ResponseWriter, r *http.Request) { 1516func (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
1617func (s *Server) mr(w http.ResponseWriter, r *http.Request) { 1613func (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.
105type 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
103func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { 115func (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.
120func (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.
101type MRFilter struct {
102 State string
103 Author string
104 Milestone string
105 Limit int
106 Before int64
107}
108
99func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) { 109func (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.
115func (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">