Commit 6194895054
Verified · cmc
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 @@ | |||
| 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 | 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 | ||
| 224 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | 226 | func (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 <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 | // 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. |
| 91 | func TestMainWidthClass(t *testing.T) { | 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 | 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() { |