Commit 6194895054
Verified · cmc
Layout: unified · split
internal/httpd/anchors_test.go +2 −1
| @@ -59,8 +59,9 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) { | ||
| 59 | 59 | Assigned []store.DashboardItem |
| 60 | 60 | MRs []store.DashboardItem |
| 61 | 61 | Issues []store.DashboardItem |
| 62 | Queries []control.DashboardQuery | |
| 62 | 63 | Feed []control.FeedLine |
| 63 | }{base, "dashboard", nil, nil, nil, nil, nil, nil}) | |
| 64 | }{base, "dashboard", nil, nil, nil, nil, nil, nil, nil}) | |
| 64 | 65 | if err != nil { |
| 65 | 66 | t.Fatal(err) |
| 66 | 67 | } |
internal/httpd/dashpins_test.go +2 −1
| @@ -24,12 +24,13 @@ func TestDashboardTilesAndPins(t *testing.T) { | ||
| 24 | 24 | Assigned []store.DashboardItem |
| 25 | 25 | MRs []store.DashboardItem |
| 26 | 26 | Issues []store.DashboardItem |
| 27 | Queries []control.DashboardQuery | |
| 27 | 28 | Feed []control.FeedLine |
| 28 | 29 | }{base, "dashboard", []pinnedRow{ |
| 29 | 30 | {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, |
| 30 | 31 | {Owner: "krz", Name: "old", Build: "cancelled"}, |
| 31 | 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 | 34 | if err != nil { |
| 34 | 35 | t.Fatal(err) |
| 35 | 36 | } |
internal/httpd/queries.go added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | const 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. | |
| 21 | func (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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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, `"`)], "&", "&") | |
| 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 | 184 | Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true, |
| 185 | 185 | Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))}, |
| 186 | 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 | 189 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)}, |
| 188 | 190 | Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true, |
| 189 | 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 | 209 | reviews, _ := s.st.ReviewQueue(viewer.ID) |
| 210 | 210 | assigned, _ := s.st.AssignedIssues(viewer.ID) |
| 211 | 211 | events, _ := s.st.RecentEvents(viewer.ID, 20, 0) |
| 212 | queries, _ := control.PinnedQueries(s.st, viewer) | |
| 212 | 213 | s.render(w, "dashboard.html", struct { |
| 213 | 214 | basePage |
| 214 | 215 | Tab string |
| @@ -217,8 +218,9 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 217 | 218 | Assigned []store.DashboardItem |
| 218 | 219 | MRs []store.DashboardItem |
| 219 | 220 | Issues []store.DashboardItem |
| 221 | Queries []control.DashboardQuery | |
| 220 | 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 | |
| 224 | 226 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
internal/web/templates/dashboard.html +16
| @@ -42,6 +42,22 @@ | ||
| 42 | 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 | 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 | 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 | 61 | </section> |
| 46 | 62 | |
| 47 | 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 <name> <query></code>; <code>gitbay query pin <name></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 | 89 | // none. The merge request page picks wide for its diff view, so it gets |
| 90 | 90 | // a per-view define instead of a fixed one. |
| 91 | 91 | func 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 | 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 | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | 95 | for _, name := range Pages() { |