Commit 6194895054

6194895054bf6b7da58f3c2b3ac6717d9c2ed8ef

parent: 7b688211e7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:32 UTC

web: pinned queries on the dashboard, /{owner}/-/queries and /{owner}/-/queries/{name}

Ref #292

Layout: unified · split

internal/httpd/anchors_test.go +2 −1
@@ -59,8 +59,9 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) {
59 Assigned []store.DashboardItem 59 Assigned []store.DashboardItem
60 MRs []store.DashboardItem 60 MRs []store.DashboardItem
61 Issues []store.DashboardItem 61 Issues []store.DashboardItem
62 Queries []control.DashboardQuery
62 Feed []control.FeedLine 63 Feed []control.FeedLine
63 }{base, "dashboard", nil, nil, nil, nil, nil, nil}) 64 }{base, "dashboard", nil, nil, nil, nil, nil, nil, nil})
64 if err != nil { 65 if err != nil {
65 t.Fatal(err) 66 t.Fatal(err)
66 } 67 }
internal/httpd/dashpins_test.go +2 −1
@@ -24,12 +24,13 @@ func TestDashboardTilesAndPins(t *testing.T) {
24 Assigned []store.DashboardItem 24 Assigned []store.DashboardItem
25 MRs []store.DashboardItem 25 MRs []store.DashboardItem
26 Issues []store.DashboardItem 26 Issues []store.DashboardItem
27 Queries []control.DashboardQuery
27 Feed []control.FeedLine 28 Feed []control.FeedLine
28 }{base, "dashboard", []pinnedRow{ 29 }{base, "dashboard", []pinnedRow{
29 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, 30 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"},
30 {Owner: "krz", Name: "old", Build: "cancelled"}, 31 {Owner: "krz", Name: "old", Build: "cancelled"},
31 }, nil, nil, nil, 32 }, nil, nil, nil,
32 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil}) 33 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil, nil})
33 if err != nil { 34 if err != nil {
34 t.Fatal(err) 35 t.Fatal(err)
35 } 36 }
internal/httpd/queries.go added +78
@@ -0,0 +1,78 @@
1package httpd
2
3import (
4 "net/http"
5 "net/url"
6 "strconv"
7
8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// queryPerPage is how many rows a saved query's page shows before
14// offering the next, through query run's own cursor.
15const queryPerPage = 50
16
17// queriesPage lists the viewer's saved queries (/{owner}/-/queries), or
18// runs one (/{owner}/-/queries/{name}) through query show and query run,
19// the reads the CLI makes. Saved queries are private: under anyone else's
20// name the page is not found.
21func (s *Server) queriesPage(w http.ResponseWriter, r *http.Request, viewer store.User) {
22 if r.PathValue("owner") != viewer.Username {
23 s.notFound(w, r)
24 return
25 }
26 name := r.PathValue("name")
27 data := struct {
28 basePage
29 Tab string
30 Saved []control.SavedQueryOut
31 Query control.SavedQueryOut
32 Count int
33 Items []control.QueryItem
34 Next string
35 }{basePage: s.baseFor(viewer), Tab: "dashboard"}
36 if name == "" {
37 if msg, ok := s.runControlInto(viewer, []string{"query", "list"}, &data.Saved); !ok {
38 http.Error(w, msg, http.StatusInternalServerError)
39 return
40 }
41 s.render(w, "queries.html", data)
42 return
43 }
44 switch code, msg := s.runControlIntoCode(viewer, []string{"query", "show", name}, &data.Query); code {
45 case protocol.ExitOK:
46 case protocol.ExitNotFound:
47 s.notFound(w, r)
48 return
49 default:
50 http.Error(w, msg, http.StatusInternalServerError)
51 return
52 }
53 if data.Query.Count != nil {
54 data.Count = *data.Query.Count
55 }
56 argv := []string{"query", "run", name, "--limit", strconv.Itoa(queryPerPage)}
57 if cursor := r.URL.Query().Get("cursor"); cursor != "" {
58 argv = append(argv, "--cursor", cursor)
59 }
60 var page struct {
61 Items []control.QueryItem `json:"items"`
62 Next string `json:"next"`
63 }
64 switch code, msg := s.runControlIntoCode(viewer, argv, &page); code {
65 case protocol.ExitOK:
66 case protocol.ExitUsage:
67 http.Error(w, msg, http.StatusBadRequest)
68 return
69 default:
70 http.Error(w, msg, http.StatusInternalServerError)
71 return
72 }
73 data.Items = page.Items
74 if page.Next != "" {
75 data.Next = "?" + url.Values{"cursor": {page.Next}}.Encode()
76 }
77 s.render(w, "queries.html", data)
78}
internal/httpd/queries_test.go added +117
@@ -0,0 +1,117 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// A pinned query shows on the dashboard and its page lists what it
14// matches, one page at a time, never another user's private rows.
15func TestSavedQueryPages(t *testing.T) {
16 st, err := store.Open(":memory:")
17 if err != nil {
18 t.Fatal(err)
19 }
20 defer st.Close()
21 if err := st.MigrateUp(); err != nil {
22 t.Fatal(err)
23 }
24 alice, err := st.CreateUser("alice", false)
25 if err != nil {
26 t.Fatal(err)
27 }
28 bob, err := st.CreateUser("bob", false)
29 if err != nil {
30 t.Fatal(err)
31 }
32 pub, err := st.CreateRepo("user", alice, "pub", "public")
33 if err != nil {
34 t.Fatal(err)
35 }
36 secret, err := st.CreateRepo("user", bob, "secret", "private")
37 if err != nil {
38 t.Fatal(err)
39 }
40 for i := 0; i < queryPerPage+1; i++ {
41 if _, err := st.CreateIssue(pub, alice, "public issue", "", "md"); err != nil {
42 t.Fatal(err)
43 }
44 }
45 if _, err := st.CreateIssue(secret, bob, "bob's secret", "", "md"); err != nil {
46 t.Fatal(err)
47 }
48 if err := st.SaveQuery(alice, "open", "is:open", false); err != nil {
49 t.Fatal(err)
50 }
51 if err := st.PinSavedQuery(alice, "open", true); err != nil {
52 t.Fatal(err)
53 }
54 cfg := config.Default()
55 cfg.Web.Mode = "accounts"
56 s := New(cfg, st, nil)
57 viewer := store.User{ID: alice, Username: "alice"}
58
59 get := func(path string) *httptest.ResponseRecorder {
60 rr := httptest.NewRecorder()
61 req := httptest.NewRequest("GET", path, nil)
62 if owner, rest, ok := strings.Cut(strings.TrimPrefix(req.URL.Path, "/"), "/-/queries"); ok {
63 req.SetPathValue("owner", owner)
64 req.SetPathValue("name", strings.TrimPrefix(rest, "/"))
65 }
66 if req.URL.Path == "/" {
67 s.dashboard(rr, req, viewer)
68 } else {
69 s.queriesPage(rr, req, viewer)
70 }
71 return rr
72 }
73
74 dash := get("/").Body.String()
75 for _, want := range []string{
76 `<a href="/alice/-/queries/open">open</a> <span class="count">51</span>`,
77 `<code>is:open</code>`,
78 `<a href="/alice/-/queries/open">all 51 →</a>`,
79 } {
80 if !strings.Contains(dash, want) {
81 t.Errorf("dashboard lacks %q", want)
82 }
83 }
84
85 first := get("/alice/-/queries/open")
86 if first.Code != http.StatusOK {
87 t.Fatalf("query page: %d %s", first.Code, first.Body.String())
88 }
89 body := first.Body.String()
90 if n := strings.Count(body, `<span class="repo">alice/pub#`); n != queryPerPage {
91 t.Errorf("first page lists %d rows, want %d", n, queryPerPage)
92 }
93 if strings.Contains(body, "secret") || strings.Contains(dash, "secret") {
94 t.Error("a private repository of another user reached the page")
95 }
96 i := strings.Index(body, `<p class="pager"><a href="?cursor=`)
97 if i < 0 {
98 t.Fatalf("no next link:\n%s", body)
99 }
100 href := body[i+len(`<p class="pager"><a href="`):]
101 href = strings.ReplaceAll(href[:strings.Index(href, `"`)], "&amp;", "&")
102 second := get("/alice/-/queries/open" + href).Body.String()
103 if n := strings.Count(second, `<span class="repo">alice/pub#`); n != 1 || !strings.Contains(second, `alice/pub#1<`) {
104 t.Errorf("second page lists %d rows, want the oldest one", n)
105 }
106
107 if rr := get("/alice/-/queries/nosuch"); rr.Code != http.StatusNotFound {
108 t.Errorf("unknown query: %d, want 404", rr.Code)
109 }
110 if rr := get("/bob/-/queries/open"); rr.Code != http.StatusNotFound {
111 t.Errorf("alice under bob's name: %d, want 404", rr.Code)
112 }
113 list := get("/alice/-/queries").Body.String()
114 if !strings.Contains(list, `<a href="/alice/-/queries/open">open</a> <span class="chip">pinned</span>`) {
115 t.Errorf("query list lacks the pinned query:\n%s", list)
116 }
117}
internal/httpd/routes.go +2
@@ -184,6 +184,8 @@ func (s *Server) Routes() []Route {
184 Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true, 184 Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true,
185 Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))}, 185 Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))},
186 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)}, 186 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
187 Route{Method: "GET", Pattern: "/{owner}/-/queries", Handler: s.requireUser(s.queriesPage)},
188 Route{Method: "GET", Pattern: "/{owner}/-/queries/{name}", Handler: s.requireUser(s.queriesPage)},
187 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)}, 189 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
188 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true, 190 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
189 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))}, 191 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
internal/httpd/web.go +3 −1
@@ -209,6 +209,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
209 reviews, _ := s.st.ReviewQueue(viewer.ID) 209 reviews, _ := s.st.ReviewQueue(viewer.ID)
210 assigned, _ := s.st.AssignedIssues(viewer.ID) 210 assigned, _ := s.st.AssignedIssues(viewer.ID)
211 events, _ := s.st.RecentEvents(viewer.ID, 20, 0) 211 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
212 queries, _ := control.PinnedQueries(s.st, viewer)
212 s.render(w, "dashboard.html", struct { 213 s.render(w, "dashboard.html", struct {
213 basePage 214 basePage
214 Tab string 215 Tab string
@@ -217,8 +218,9 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
217 Assigned []store.DashboardItem 218 Assigned []store.DashboardItem
218 MRs []store.DashboardItem 219 MRs []store.DashboardItem
219 Issues []store.DashboardItem 220 Issues []store.DashboardItem
221 Queries []control.DashboardQuery
220 Feed []control.FeedLine 222 Feed []control.FeedLine
221 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, control.FeedLines(events)}) 223 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)})
222} 224}
223 225
224func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 226func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
internal/web/templates/dashboard.html +16
@@ -42,6 +42,22 @@
42{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Your MRs across all repos."}}{{end}} 42{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Your MRs across all repos."}}{{end}}
43{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Your issues across all repos."}}{{end}} 43{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Your issues across all repos."}}{{end}}
44{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open.</p>{{end}} 44{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open.</p>{{end}}
45{{range .Queries}}
46<h2 id="query-{{.Name}}"><a href="/{{$.Viewer}}/-/queries/{{.Name}}">{{.Name}}</a> <span class="count">{{.Count}}</span></h2>
47<p class="hint"><code>{{.Query}}</code></p>
48{{if .Error}}<p class="none">{{.Error}}</p>{{else}}
49<ul class="issuelist rows">
50{{range .Items}}<li>
51 <div class="issuemain">
52 <p class="title"><a href="/{{.Repo}}/{{if eq .Kind "mr"}}mrs{{else}}issues{{end}}/{{.Number}}">{{.Title}}</a></p>
53 <p class="meta"><span class="repo">{{.Ref}}</span> · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}</p>
54 </div>
55</li>
56{{else}}<li class="empty">Nothing matches</li>{{end}}
57</ul>
58{{if gt .Count (len .Items)}}<p class="pager"><a href="/{{$.Viewer}}/-/queries/{{.Name}}">all {{.Count}} →</a></p>{{end}}
59{{end}}
60{{end}}
45</section> 61</section>
46 62
47<aside class="feedcol" aria-label="Recent activity"> 63<aside class="feedcol" aria-label="Recent activity">
internal/web/templates/queries.html added +35
@@ -0,0 +1,35 @@
1{{define "width"}}wide{{end}}
2{{define "title"}}{{if .Query.Name}}{{.Query.Name}} · {{end}}queries · {{.Site}}{{end}}
3{{define "content"}}
4{{if .Query.Name}}
5<div class="listhead">
6 <h1>{{.Query.Name}} <span class="count">{{.Count}}</span></h1>
7 <span class="spacer"></span>
8 <p class="meta"><a href="/{{.Viewer}}/-/queries">saved queries</a></p>
9</div>
10<p class="meta"><code>{{.Query.Query}}</code> · the same read as <code>gitbay query run {{.Query.Name}}</code></p>
11<ul class="issuelist rows">
12{{range .Items}}<li>
13 <div class="issuemain">
14 <p class="title"><a href="/{{.Repo}}/{{if eq .Kind "mr"}}mrs{{else}}issues{{end}}/{{.Number}}">{{.Title}}</a></p>
15 <p class="meta"><span class="repo">{{.Ref}}</span> · <a href="/{{.Author}}">{{.Author}}</a>{{if .Milestone}} · in {{.Milestone}}{{end}} · {{when .CreatedAt}}</p>
16 </div>
17 <span class="chip {{if eq .State "open"}}chip-open{{else if eq .State "merged"}}chip-done{{else}}chip-closed{{end}}">{{if .Draft}}draft{{else}}{{.State}}{{end}}</span>
18</li>
19{{else}}<li class="empty">nothing matches</li>{{end}}
20</ul>
21{{if .Next}}<p class="pager"><a href="{{.Next}}">older →</a></p>{{end}}
22{{else}}
23<h1>Saved queries</h1>
24<p class="meta">Saved with <code>gitbay query save &lt;name&gt; &lt;query&gt;</code>; <code>gitbay query pin &lt;name&gt;</code> puts one on the dashboard.</p>
25<ul class="issuelist rows">
26{{range .Saved}}<li>
27 <div class="issuemain">
28 <p class="title"><a href="/{{$.Viewer}}/-/queries/{{.Name}}">{{.Name}}</a>{{if .Pinned}} <span class="chip">pinned</span>{{end}}</p>
29 <p class="meta"><code>{{.Query}}</code></p>
30 </div>
31</li>
32{{else}}<li class="empty">no saved queries</li>{{end}}
33</ul>
34{{end}}
35{{end}}
internal/web/web_test.go +1 −1
@@ -89,7 +89,7 @@ func TestWhenNamesTheZone(t *testing.T) {
89// none. The merge request page picks wide for its diff view, so it gets 89// none. The merge request page picks wide for its diff view, so it gets
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {