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 {
402402 return group("issue", "issues",
403403 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
404404 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}),
406406 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
407407 pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
408408 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
@@ -448,7 +448,7 @@ func mrCmd() *cobra.Command {
448448 return group("mr", "merge requests",
449449 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
450450 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}),
452452 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
453453 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
454454 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() {
2121 ReadsStdin: true, Run: runIssueCreate})
2222 register(Command{Path: []string{"issue", "list"},
2323 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})
2525 register(Command{Path: []string{"issue", "show"},
2626 Summary: "show an issue with comments",
2727 Usage: "issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
@@ -198,31 +198,44 @@ func runIssueList(c *Ctx, args []string) int {
198198 if code >= 0 {
199199 return code
200200 }
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"}
202203 var path string
203204 for i := 0; i < len(args); i++ {
205 var target *string
204206 switch args[i] {
205207 case "--state":
206 if i+1 >= len(args) {
207 return c.fail(protocol.ExitUsage, "--state requires open|closed|all")
208 }
209 state = args[i+1]
210 i++
208 target = &f.State
209 case "--label":
210 target = &f.Label
211 case "--assignee":
212 target = &f.Assignee
213 case "--author":
214 target = &f.Author
215 case "--milestone":
216 target = &f.Milestone
211217 default:
212 if path != "" {
213 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
218 if path != "" || strings.HasPrefix(args[i], "--") {
219 return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage)
214220 }
215221 path = args[i]
222 continue
223 }
224 if i+1 >= len(args) {
225 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
216226 }
227 *target = args[i+1]
228 i++
217229 }
218 if path == "" || (state != "open" && state != "closed" && state != "all") {
219 return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]")
230 if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
231 return c.fail(protocol.ExitUsage, usage)
220232 }
221233 repo, code := resolveRepo(c, path, policy.CanRead)
222234 if code >= 0 {
223235 return code
224236 }
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)
226239 if err != nil {
227240 return c.fail(protocol.ExitFailure, "%v", err)
228241 }
internal/control/mr.go +21 −12
@@ -38,7 +38,7 @@ func init() {
3838 ReadsStdin: true, Run: runMRCreate})
3939 register(Command{Path: []string{"mr", "list"},
4040 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})
4242 register(Command{Path: []string{"mr", "show"},
4343 Summary: "show a merge request",
4444 Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
@@ -410,32 +410,41 @@ func runMRList(c *Ctx, args []string) int {
410410 if code >= 0 {
411411 return code
412412 }
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"}
414415 var path string
415416 for i := 0; i < len(args); i++ {
417 var target *string
416418 switch args[i] {
417419 case "--state":
418 if i+1 >= len(args) {
419 return c.fail(protocol.ExitUsage, "--state requires a value")
420 }
421 state = args[i+1]
422 i++
420 target = &f.State
421 case "--author":
422 target = &f.Author
423 case "--milestone":
424 target = &f.Milestone
423425 default:
424 if path != "" {
425 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
426 if path != "" || strings.HasPrefix(args[i], "--") {
427 return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage)
426428 }
427429 path = args[i]
430 continue
431 }
432 if i+1 >= len(args) {
433 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
428434 }
435 *target = args[i+1]
436 i++
429437 }
430438 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
431 if path == "" || !valid[state] {
432 return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]")
439 if path == "" || !valid[f.State] {
440 return c.fail(protocol.ExitUsage, usage)
433441 }
434442 repo, code := resolveRepo(c, path, policy.CanRead)
435443 if code >= 0 {
436444 return code
437445 }
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)
439448 if err != nil {
440449 return c.fail(protocol.ExitFailure, "%v", err)
441450 }
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) {
14871487 if state != "closed" && state != "all" {
14881488 state = "open"
14891489 }
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)
14911496 if err != nil {
14921497 http.Error(w, "internal error", http.StatusInternalServerError)
14931498 return
@@ -1497,27 +1502,15 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
14971502 issues[i].Labels = labels[issues[i].ID]
14981503 }
14991504 }
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 }
15141505 s.render(w, "issues.html", struct {
15151506 repoPage
15161507 State string
15171508 Label string
1509 Filters []listFilter
15181510 Issues []store.Issue
15191511 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)})
15211514}
15221515
15231516func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -1602,16 +1595,19 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
16021595 if !valid[state] {
16031596 state = "open"
16041597 }
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)
16061601 if err != nil {
16071602 http.Error(w, "internal error", http.StatusInternalServerError)
16081603 return
16091604 }
16101605 s.render(w, "mrs.html", struct {
16111606 repoPage
1612 State string
1613 MRs []store.MR
1614 }{p, state, mrs})
1607 State string
1608 Filters []listFilter
1609 MRs []store.MR
1610 }{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs})
16151611}
16161612
16171613func (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) {
100100// ListIssues returns issues for a repo; state is "open", "closed", or
101101// "all". limit 0 means everything; before (an issue number) starts the
102102// 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
103115func (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) {
104121 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state,
105122 COALESCE(m.title, ''), i.created_at, i.updated_at
106123 FROM issues i JOIN users u ON u.id = i.author_id
107124 LEFT JOIN milestones m ON m.id = i.milestone_id
108125 WHERE i.repo_id = ?`
109126 args := []any{repoID}
110 if state != "all" {
127 if f.State != "" && f.State != "all" {
111128 q += " AND i.state = ?"
112 args = append(args, state)
113 }
114 if before > 0 {
129 args = append(args, f.State)
130 }
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 {
115154 q += " AND i.number < ?"
116 args = append(args, before)
155 args = append(args, f.Before)
117156 }
118157 q += " ORDER BY i.number DESC"
119 if limit > 0 {
158 if f.Limit > 0 {
120159 q += " LIMIT ?"
121 args = append(args, limit)
160 args = append(args, f.Limit)
122161 }
123162 rows, err := s.DB.Query(q, args...)
124163 if err != nil {
internal/store/mrs.go +34 −6
@@ -96,21 +96,49 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
9696// ListMRs returns merge requests for a repo. limit 0 means everything;
9797// before (an MR number) starts the page strictly below it, matching the
9898// 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
99109func (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) {
100116 q := mrSelect + " WHERE m.repo_id = ?"
101117 args := []any{repoID}
102 if state != "all" {
118 if f.State != "" && f.State != "all" {
103119 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)
105133 }
106 if before > 0 {
134 if f.Before > 0 {
107135 q += " AND m.number < ?"
108 args = append(args, before)
136 args = append(args, f.Before)
109137 }
110138 q += " ORDER BY m.number DESC"
111 if limit > 0 {
139 if f.Limit > 0 {
112140 q += " LIMIT ?"
113 args = append(args, limit)
141 args = append(args, f.Limit)
114142 }
115143 rows, err := s.DB.Query(q, args...)
116144 if err != nil {
internal/web/templates/issues.html +1 −1
@@ -7,7 +7,7 @@
77 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
88 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
99 </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}}
1111 <span class="spacer"></span>
1212 <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>
1313</div>
internal/web/templates/mrs.html +1
@@ -8,6 +8,7 @@
88 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
99 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
1010 </nav>
11 {{range .Filters}}<p class="meta">{{.Key}}: <b>{{.Value}}</b> <a href="{{.Clear}}">clear</a></p>{{end}}
1112</div>
1213{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
1314<ul class="issuelist">