Commit 43e45ac3a9
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/profile_test.go +2 −2
| @@ -214,10 +214,10 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 214 | if !strings.Contains(body, `class="activity"`) { | 214 | if !strings.Contains(body, `class="activity"`) { |
| 215 | t.Error("the about tab has no activity graph") | 215 | t.Error("the about tab has no activity graph") |
| 216 | } | 216 | } |
| 217 | if strings.Contains(body, `<ul class="repolist"`) { | 217 | if strings.Contains(body, `<ul class="repolist`) { |
| 218 | t.Error("the repository list still sits under the about text") | 218 | t.Error("the repository list still sits under the about text") |
| 219 | } | 219 | } |
| 220 | if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist"`) { | 220 | if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist rows">`) { |
| 221 | t.Error("the repositories tab has no repository list") | 221 | t.Error("the repositories tab has no repository list") |
| 222 | } | 222 | } |
| 223 | 223 | ||
internal/httpd/explorepage_test.go +45 −8
| @@ -1,6 +1,7 @@ | |||
| 1 | package httpd | 1 | package httpd |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | ||
| 4 | "strings" | 5 | "strings" |
| 5 | "testing" | 6 | "testing" |
| 6 | 7 | ||
| @@ -40,17 +41,53 @@ func TestExplorePageSummarisesTheFilter(t *testing.T) { | |||
| 40 | } | 41 | } |
| 41 | 42 | ||
| 42 | func renderExplore(t *testing.T, q string, repos []describedRepo) string { | 43 | func renderExplore(t *testing.T, q string, repos []describedRepo) string { |
| 44 | t.Helper() | ||
| 45 | p := explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Query: q} | ||
| 46 | p.Repos, p.Total, p.Page, p.Pages = pageOf(repos, "") | ||
| 47 | return renderExplorePage(t, p) | ||
| 48 | } | ||
| 49 | |||
| 50 | func renderExplorePage(t *testing.T, p explorePage) string { | ||
| 43 | t.Helper() | 51 | t.Helper() |
| 44 | var sb strings.Builder | 52 | var sb strings.Builder |
| 45 | err := web.Render(&sb, "explore.html", struct { | 53 | if err := web.Render(&sb, "explore.html", p); err != nil { |
| 46 | basePage | ||
| 47 | Tab string | ||
| 48 | Query string | ||
| 49 | Facets []facetGroup | ||
| 50 | Repos []describedRepo | ||
| 51 | }{basePage{Site: "gitbay"}, "explore", q, nil, repos}) | ||
| 52 | if err != nil { | ||
| 53 | t.Fatalf("render: %v", err) | 54 | t.Fatalf("render: %v", err) |
| 54 | } | 55 | } |
| 55 | return sb.String() | 56 | return sb.String() |
| 56 | } | 57 | } |
| 58 | |||
| 59 | func TestExplorePages(t *testing.T) { | ||
| 60 | var repos []describedRepo | ||
| 61 | for i := range 45 { | ||
| 62 | repos = append(repos, describedRepo{Repo: store.Repo{OwnerName: "krz", Name: fmt.Sprintf("r%02d", i)}}) | ||
| 63 | } | ||
| 64 | for _, tc := range []struct { | ||
| 65 | in string | ||
| 66 | page, len int | ||
| 67 | first string | ||
| 68 | }{ | ||
| 69 | {"", 1, 20, "r00"}, {"2", 2, 20, "r20"}, {"3", 3, 5, "r40"}, | ||
| 70 | {"9", 3, 5, "r40"}, {"0", 1, 20, "r00"}, {"x", 1, 20, "r00"}, | ||
| 71 | } { | ||
| 72 | got, total, page, pages := pageOf(repos, tc.in) | ||
| 73 | if total != 45 || pages != 3 || page != tc.page || len(got) != tc.len || got[0].Name != tc.first { | ||
| 74 | t.Errorf("page %q: total %d, page %d of %d, %d rows from %s", tc.in, total, page, pages, len(got), got[0].Name) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | if got, _, page, pages := pageOf(nil, "2"); len(got) != 0 || page != 1 || pages != 1 { | ||
| 78 | t.Errorf("empty listing: %d rows, page %d of %d", len(got), page, pages) | ||
| 79 | } | ||
| 80 | |||
| 81 | p := explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Query: "go"} | ||
| 82 | p.Repos, p.Total, p.Page, p.Pages = pageOf(repos, "2") | ||
| 83 | p.Prev, p.Next = explorePageURL("go", 1), explorePageURL("go", 3) | ||
| 84 | out := renderExplorePage(t, p) | ||
| 85 | for _, want := range []string{"45 repositories", "page 2 of 3", `href="/explore?q=go"`, `href="/explore?page=3&q=go"`} { | ||
| 86 | if !strings.Contains(out, want) { | ||
| 87 | t.Errorf("explore.html missing %q:\n%s", want, out) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | if out := renderExplore(t, "", repos[:3]); strings.Contains(out, `class="pager"`) { | ||
| 91 | t.Errorf("a single page has a pager:\n%s", out) | ||
| 92 | } | ||
| 93 | } | ||
internal/httpd/railanon_test.go +1 −7
| @@ -14,13 +14,7 @@ import ( | |||
| 14 | // signed-out phone had no route to the listing from any page (#248). | 14 | // signed-out phone had no route to the listing from any page (#248). |
| 15 | func TestSignedOutRailDropsNothing(t *testing.T) { | 15 | func TestSignedOutRailDropsNothing(t *testing.T) { |
| 16 | var sb strings.Builder | 16 | var sb strings.Builder |
| 17 | err := web.Render(&sb, "explore.html", struct { | 17 | err := web.Render(&sb, "explore.html", explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Pages: 1}) |
| 18 | basePage | ||
| 19 | Tab string | ||
| 20 | Query string | ||
| 21 | Facets []facetGroup | ||
| 22 | Repos []describedRepo | ||
| 23 | }{basePage{Site: "gitbay"}, "explore", "", nil, nil}) | ||
| 24 | if err != nil { | 18 | if err != nil { |
| 25 | t.Fatalf("render: %v", err) | 19 | t.Fatalf("render: %v", err) |
| 26 | } | 20 | } |
internal/httpd/web.go +53 −8
| @@ -223,8 +223,26 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | |||
| 223 | }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)}) | 223 | }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)}) |
| 224 | } | 224 | } |
| 225 | 225 | ||
| 226 | // explorePageSize is how many repositories one page of /explore lists. | ||
| 227 | const explorePageSize = 20 | ||
| 228 | |||
| 229 | // explorePage is /explore: one page of the filtered listing, most recent | ||
| 230 | // activity first. Total counts the whole filtered listing. | ||
| 231 | type explorePage struct { | ||
| 232 | basePage | ||
| 233 | Tab string | ||
| 234 | Query string | ||
| 235 | Facets []facetGroup | ||
| 236 | Repos []describedRepo | ||
| 237 | Total int | ||
| 238 | Page int | ||
| 239 | Pages int | ||
| 240 | Prev string | ||
| 241 | Next string | ||
| 242 | } | ||
| 243 | |||
| 226 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | 244 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| 227 | repos, err := s.st.ListPublicRepos() | 245 | repos, err := s.st.ListPublicReposByActivity() |
| 228 | if err != nil { | 246 | if err != nil { |
| 229 | http.Error(w, "internal error", http.StatusInternalServerError) | 247 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 230 | return | 248 | return |
| @@ -235,13 +253,40 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | |||
| 235 | } | 253 | } |
| 236 | q := strings.TrimSpace(r.URL.Query().Get("q")) | 254 | q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 237 | described := s.describeAll(repos) | 255 | described := s.describeAll(repos) |
| 238 | s.render(w, "explore.html", struct { | 256 | p := explorePage{basePage: s.baseFor(viewer), Tab: "explore", Query: q, |
| 239 | basePage | 257 | Facets: []facetGroup{topicFacets(described, q)}} |
| 240 | Tab string | 258 | p.Repos, p.Total, p.Page, p.Pages = pageOf(s.filterRepos(q, described), r.URL.Query().Get("page")) |
| 241 | Query string | 259 | if p.Page > 1 { |
| 242 | Facets []facetGroup | 260 | p.Prev = explorePageURL(q, p.Page-1) |
| 243 | Repos []describedRepo | 261 | } |
| 244 | }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)}) | 262 | if p.Page < p.Pages { |
| 263 | p.Next = explorePageURL(q, p.Page+1) | ||
| 264 | } | ||
| 265 | s.render(w, "explore.html", p) | ||
| 266 | } | ||
| 267 | |||
| 268 | // pageOf returns page n (1-based, clamped) of repos at explorePageSize. | ||
| 269 | func pageOf(repos []describedRepo, n string) (page []describedRepo, total, num, pages int) { | ||
| 270 | total = len(repos) | ||
| 271 | pages = max(1, (total+explorePageSize-1)/explorePageSize) | ||
| 272 | num, _ = strconv.Atoi(n) | ||
| 273 | num = min(max(num, 1), pages) | ||
| 274 | lo := (num - 1) * explorePageSize | ||
| 275 | return repos[lo:min(lo+explorePageSize, total)], total, num, pages | ||
| 276 | } | ||
| 277 | |||
| 278 | func explorePageURL(q string, page int) string { | ||
| 279 | v := url.Values{} | ||
| 280 | if q != "" { | ||
| 281 | v.Set("q", q) | ||
| 282 | } | ||
| 283 | if page > 1 { | ||
| 284 | v.Set("page", strconv.Itoa(page)) | ||
| 285 | } | ||
| 286 | if len(v) == 0 { | ||
| 287 | return "/explore" | ||
| 288 | } | ||
| 289 | return "/explore?" + v.Encode() | ||
| 245 | } | 290 | } |
| 246 | 291 | ||
| 247 | // privacy renders the privacy page: what the gitbay software does with | 292 | // privacy renders the privacy page: what the gitbay software does with |
internal/store/explore_test.go added +51
| @@ -0,0 +1,51 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestListPublicReposByActivity(t *testing.T) { | ||
| 6 | s := open(t) | ||
| 7 | if err := s.MigrateUp(); err != nil { | ||
| 8 | t.Fatal(err) | ||
| 9 | } | ||
| 10 | uid, err := s.CreateUser("cmc", true) | ||
| 11 | if err != nil { | ||
| 12 | t.Fatal(err) | ||
| 13 | } | ||
| 14 | ids := map[string]int64{} | ||
| 15 | for _, name := range []string{"aa", "bb", "cc", "dd", "secret"} { | ||
| 16 | vis := "public" | ||
| 17 | if name == "secret" { | ||
| 18 | vis = "private" | ||
| 19 | } | ||
| 20 | if ids[name], err = s.CreateRepo("user", uid, name, vis); err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | } | ||
| 24 | for _, ev := range []struct{ repo, kind string }{ | ||
| 25 | {"aa", "push"}, {"cc", "issue.created"}, {"bb", "push"}, {"aa", "build.finished"}, {"secret", "push"}, | ||
| 26 | } { | ||
| 27 | if err := s.RecordEvent(ids[ev.repo], uid, ev.kind, "{}"); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | repos, err := s.ListPublicReposByActivity() | ||
| 33 | if err != nil { | ||
| 34 | t.Fatal(err) | ||
| 35 | } | ||
| 36 | var got []string | ||
| 37 | for _, r := range repos { | ||
| 38 | got = append(got, r.Name) | ||
| 39 | } | ||
| 40 | // bb was touched last; a build on aa is not activity; dd has none | ||
| 41 | // and comes after every repo that does. | ||
| 42 | want := []string{"bb", "cc", "aa", "dd"} | ||
| 43 | if len(got) != len(want) { | ||
| 44 | t.Fatalf("got %v, want %v", got, want) | ||
| 45 | } | ||
| 46 | for i := range want { | ||
| 47 | if got[i] != want[i] { | ||
| 48 | t.Fatalf("got %v, want %v", got, want) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
internal/store/repos.go +23
| @@ -460,6 +460,29 @@ func (s *Store) ListPublicRepos() ([]Repo, error) { | |||
| 460 | return out, rows.Err() | 460 | return out, rows.Err() |
| 461 | } | 461 | } |
| 462 | 462 | ||
| 463 | // ListPublicReposByActivity returns all public repositories, the most | ||
| 464 | // recent event first. Builds are not activity: a nightly job would keep | ||
| 465 | // its repository on top. A repository with no events sorts last, newest | ||
| 466 | // first. | ||
| 467 | func (s *Store) ListPublicReposByActivity() ([]Repo, error) { | ||
| 468 | rows, err := s.DB.Query(repoSelect + ` WHERE r.visibility = 'public' | ||
| 469 | ORDER BY COALESCE((SELECT MAX(e.id) FROM events e | ||
| 470 | WHERE e.repo_id = r.id AND e.kind NOT LIKE 'build.%'), 0) DESC, r.id DESC`) | ||
| 471 | if err != nil { | ||
| 472 | return nil, err | ||
| 473 | } | ||
| 474 | defer rows.Close() | ||
| 475 | var out []Repo | ||
| 476 | for rows.Next() { | ||
| 477 | r, err := scanRepo(rows) | ||
| 478 | if err != nil { | ||
| 479 | return nil, err | ||
| 480 | } | ||
| 481 | out = append(out, r) | ||
| 482 | } | ||
| 483 | return out, rows.Err() | ||
| 484 | } | ||
| 485 | |||
| 463 | // ListForks returns the repositories forked from one repo. The caller | 486 | // ListForks returns the repositories forked from one repo. The caller |
| 464 | // filters by what the viewer may see. | 487 | // filters by what the viewer may see. |
| 465 | func (s *Store) ListForks(repoID int64) ([]Repo, error) { | 488 | func (s *Store) ListForks(repoID int64) ([]Repo, error) { |
internal/web/templates/explore.html +2 −1
| @@ -12,11 +12,12 @@ | |||
| 12 | </form> | 12 | </form> |
| 13 | <span class="spacer"></span> | 13 | <span class="spacer"></span> |
| 14 | </div> | 14 | </div> |
| 15 | <p class="meta">{{len .Repos}} repositor{{if eq (len .Repos) 1}}y{{else}}ies{{end}}{{if .Query}} matching <strong>{{.Query}}</strong>, <a href="/explore">clear filter</a>{{end}}</p> | 15 | <p class="meta">{{.Total}} repositor{{if eq .Total 1}}y{{else}}ies{{end}}{{if .Query}} matching <strong>{{.Query}}</strong>, <a href="/explore">clear filter</a>{{end}}</p> |
| 16 | <ul class="repolist rows"> | 16 | <ul class="repolist rows"> |
| 17 | {{range .Repos}}{{template "reporow" .}} | 17 | {{range .Repos}}{{template "reporow" .}} |
| 18 | {{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}} | 18 | {{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}} |
| 19 | </ul> | 19 | </ul> |
| 20 | {{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{with .Prev}} · <a href="{{.}}">← newer</a>{{end}}{{with .Next}} · <a href="{{.}}">older →</a>{{end}}</p>{{end}} | ||
| 20 | </div> | 21 | </div> |
| 21 | </div> | 22 | </div> |
| 22 | {{end}} | 23 | {{end}} |
internal/web/templates/layout.html +1 −1
| @@ -88,7 +88,7 @@ | |||
| 88 | </main> | 88 | </main> |
| 89 | 89 | ||
| 90 | <footer> | 90 | <footer> |
| 91 | <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="https://apps.apple.com/us/app/gitbay/id6806399095">iOS app</a> · <a href="/privacy">Privacy</a>{{with field . "TermsURL"}} · <a href="{{.}}">Terms</a>{{end}}{{with field . "AbuseURL"}} · <a href="{{.}}">Report abuse</a>{{end}}</p> | 91 | <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a> · <a href="https://apps.apple.com/us/app/gitbay/id6806399095">iOS app</a> · <a href="/privacy">Privacy</a>{{with field . "TermsURL"}} · <a href="{{.}}">Terms</a>{{end}}{{with field . "AbuseURL"}} · <a href="{{.}}">Report abuse</a>{{end}}</p> |
| 92 | </footer> | 92 | </footer> |
| 93 | </div> | 93 | </div> |
| 94 | </div> | 94 | </div> |
internal/web/templates/owner.html +1 −1
| @@ -46,7 +46,7 @@ | |||
| 46 | {{end}} | 46 | {{end}} |
| 47 | 47 | ||
| 48 | {{if eq .Tab "repos"}} | 48 | {{if eq .Tab "repos"}} |
| 49 | <ul class="repolist"> | 49 | <ul class="repolist rows"> |
| 50 | {{range .Repos}}{{template "reporow" .}} | 50 | {{range .Repos}}{{template "reporow" .}} |
| 51 | {{else}}<li class="empty">no visible repositories</li>{{end}} | 51 | {{else}}<li class="empty">no visible repositories</li>{{end}} |
| 52 | </ul> | 52 | </ul> |
internal/web/web.go +2 −36
| @@ -9,11 +9,9 @@ import ( | |||
| 9 | "io" | 9 | "io" |
| 10 | "io/fs" | 10 | "io/fs" |
| 11 | "reflect" | 11 | "reflect" |
| 12 | "runtime/debug" | ||
| 13 | "sort" | 12 | "sort" |
| 14 | "strconv" | 13 | "strconv" |
| 15 | "strings" | 14 | "strings" |
| 16 | "sync" | ||
| 17 | "time" | 15 | "time" |
| 18 | ) | 16 | ) |
| 19 | 17 | ||
| @@ -32,36 +30,6 @@ var FontFS embed.FS | |||
| 32 | //go:embed static/img/*.gif static/img/*.webm static/img/*.mp4 | 30 | //go:embed static/img/*.gif static/img/*.webm static/img/*.mp4 |
| 33 | var ImageFS embed.FS | 31 | var ImageFS embed.FS |
| 34 | 32 | ||
| 35 | // version returns the short VCS revision baked into the binary, or "" when | ||
| 36 | // built outside a checkout. Used by the layout footer. | ||
| 37 | var version = sync.OnceValue(func() string { | ||
| 38 | info, ok := debug.ReadBuildInfo() | ||
| 39 | if !ok { | ||
| 40 | return "" | ||
| 41 | } | ||
| 42 | for _, s := range info.Settings { | ||
| 43 | if s.Key == "vcs.revision" && len(s.Value) >= 10 { | ||
| 44 | return s.Value[:10] | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return "" | ||
| 48 | }) | ||
| 49 | |||
| 50 | // fullVersion is the complete VCS revision, for linking the footer hash | ||
| 51 | // to the upstream commit page. | ||
| 52 | var fullVersion = sync.OnceValue(func() string { | ||
| 53 | info, ok := debug.ReadBuildInfo() | ||
| 54 | if !ok { | ||
| 55 | return "" | ||
| 56 | } | ||
| 57 | for _, s := range info.Settings { | ||
| 58 | if s.Key == "vcs.revision" { | ||
| 59 | return s.Value | ||
| 60 | } | ||
| 61 | } | ||
| 62 | return "" | ||
| 63 | }) | ||
| 64 | |||
| 65 | // StyleVersion identifies the bytes /static/style.css serves. The httpd | 33 | // StyleVersion identifies the bytes /static/style.css serves. The httpd |
| 66 | // package sets it; the layout stamps it on the stylesheet URL so a deploy | 34 | // package sets it; the layout stamps it on the stylesheet URL so a deploy |
| 67 | // changes that URL and a browser holding a cached copy cannot miss it. | 35 | // changes that URL and a browser holding a cached copy cannot miss it. |
| @@ -127,10 +95,8 @@ func railOptItems(v any) []railItem { | |||
| 127 | } | 95 | } |
| 128 | 96 | ||
| 129 | var funcs = template.FuncMap{ | 97 | var funcs = template.FuncMap{ |
| 130 | "gitbayVersion": func() string { return version() }, | 98 | "styleVersion": func() string { return StyleVersion }, |
| 131 | "gitbayCommit": func() string { return fullVersion() }, | 99 | "join": strings.Join, |
| 132 | "styleVersion": func() string { return StyleVersion }, | ||
| 133 | "join": strings.Join, | ||
| 134 | // paragraphs splits plain text on blank lines for safe rich display. | 100 | // paragraphs splits plain text on blank lines for safe rich display. |
| 135 | "paragraphs": func(s string) []string { | 101 | "paragraphs": func(s string) []string { |
| 136 | var out []string | 102 | var out []string |
internal/web/widths_test.go +4 −2
| @@ -31,8 +31,10 @@ func TestListPagesAreWide(t *testing.T) { | |||
| 31 | t.Errorf("%s does not use one-line rows", name) | 31 | t.Errorf("%s does not use one-line rows", name) |
| 32 | } | 32 | } |
| 33 | } | 33 | } |
| 34 | if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) { | 34 | for _, name := range []string{"explore.html", "owner.html"} { |
| 35 | t.Error("explore.html does not use one-line rows") | 35 | if src, _ := templateFS.ReadFile("templates/" + name); !strings.Contains(string(src), `<ul class="repolist rows">`) { |
| 36 | t.Errorf("%s does not use one-line repository rows", name) | ||
| 37 | } | ||
| 36 | } | 38 | } |
| 37 | // Settings pages carry a section column: every section id has a link | 39 | // Settings pages carry a section column: every section id has a link |
| 38 | // in the column, and the page is wide with the narrow grid. | 40 | // in the column, and the page is wide with the narrow grid. |