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 | 214 | if !strings.Contains(body, `class="activity"`) { |
| 215 | 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 | 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 | 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 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 4 | 5 | "strings" |
| 5 | 6 | "testing" |
| 6 | 7 | |
| @@ -40,17 +41,53 @@ func TestExplorePageSummarisesTheFilter(t *testing.T) { | ||
| 40 | 41 | } |
| 41 | 42 | |
| 42 | 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 | 51 | t.Helper() |
| 44 | 52 | var sb strings.Builder |
| 45 | err := web.Render(&sb, "explore.html", struct { | |
| 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 | if err := web.Render(&sb, "explore.html", p); err != nil { | |
| 53 | 54 | t.Fatalf("render: %v", err) |
| 54 | 55 | } |
| 55 | 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 | 14 | // signed-out phone had no route to the listing from any page (#248). |
| 15 | 15 | func TestSignedOutRailDropsNothing(t *testing.T) { |
| 16 | 16 | var sb strings.Builder |
| 17 | err := web.Render(&sb, "explore.html", struct { | |
| 18 | basePage | |
| 19 | Tab string | |
| 20 | Query string | |
| 21 | Facets []facetGroup | |
| 22 | Repos []describedRepo | |
| 23 | }{basePage{Site: "gitbay"}, "explore", "", nil, nil}) | |
| 17 | err := web.Render(&sb, "explore.html", explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Pages: 1}) | |
| 24 | 18 | if err != nil { |
| 25 | 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 | 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 | 244 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| 227 | repos, err := s.st.ListPublicRepos() | |
| 245 | repos, err := s.st.ListPublicReposByActivity() | |
| 228 | 246 | if err != nil { |
| 229 | 247 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 230 | 248 | return |
| @@ -235,13 +253,40 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | ||
| 235 | 253 | } |
| 236 | 254 | q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 237 | 255 | described := s.describeAll(repos) |
| 238 | s.render(w, "explore.html", struct { | |
| 239 | basePage | |
| 240 | Tab string | |
| 241 | Query string | |
| 242 | Facets []facetGroup | |
| 243 | Repos []describedRepo | |
| 244 | }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)}) | |
| 256 | p := explorePage{basePage: s.baseFor(viewer), Tab: "explore", Query: q, | |
| 257 | Facets: []facetGroup{topicFacets(described, q)}} | |
| 258 | p.Repos, p.Total, p.Page, p.Pages = pageOf(s.filterRepos(q, described), r.URL.Query().Get("page")) | |
| 259 | if p.Page > 1 { | |
| 260 | p.Prev = explorePageURL(q, p.Page-1) | |
| 261 | } | |
| 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 | 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 | 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 | 486 | // ListForks returns the repositories forked from one repo. The caller |
| 464 | 487 | // filters by what the viewer may see. |
| 465 | 488 | func (s *Store) ListForks(repoID int64) ([]Repo, error) { |
internal/web/templates/explore.html +2 −1
| @@ -12,11 +12,12 @@ | ||
| 12 | 12 | </form> |
| 13 | 13 | <span class="spacer"></span> |
| 14 | 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 | 16 | <ul class="repolist rows"> |
| 17 | 17 | {{range .Repos}}{{template "reporow" .}} |
| 18 | 18 | {{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}} |
| 19 | 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 | 21 | </div> |
| 21 | 22 | </div> |
| 22 | 23 | {{end}} |
internal/web/templates/layout.html +1 −1
| @@ -88,7 +88,7 @@ | ||
| 88 | 88 | </main> |
| 89 | 89 | |
| 90 | 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 | 92 | </footer> |
| 93 | 93 | </div> |
| 94 | 94 | </div> |
internal/web/templates/owner.html +1 −1
| @@ -46,7 +46,7 @@ | ||
| 46 | 46 | {{end}} |
| 47 | 47 | |
| 48 | 48 | {{if eq .Tab "repos"}} |
| 49 | <ul class="repolist"> | |
| 49 | <ul class="repolist rows"> | |
| 50 | 50 | {{range .Repos}}{{template "reporow" .}} |
| 51 | 51 | {{else}}<li class="empty">no visible repositories</li>{{end}} |
| 52 | 52 | </ul> |
internal/web/web.go +2 −36
| @@ -9,11 +9,9 @@ import ( | ||
| 9 | 9 | "io" |
| 10 | 10 | "io/fs" |
| 11 | 11 | "reflect" |
| 12 | "runtime/debug" | |
| 13 | 12 | "sort" |
| 14 | 13 | "strconv" |
| 15 | 14 | "strings" |
| 16 | "sync" | |
| 17 | 15 | "time" |
| 18 | 16 | ) |
| 19 | 17 | |
| @@ -32,36 +30,6 @@ var FontFS embed.FS | ||
| 32 | 30 | //go:embed static/img/*.gif static/img/*.webm static/img/*.mp4 |
| 33 | 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 | 33 | // StyleVersion identifies the bytes /static/style.css serves. The httpd |
| 66 | 34 | // package sets it; the layout stamps it on the stylesheet URL so a deploy |
| 67 | 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 | 97 | var funcs = template.FuncMap{ |
| 130 | "gitbayVersion": func() string { return version() }, | |
| 131 | "gitbayCommit": func() string { return fullVersion() }, | |
| 132 | "styleVersion": func() string { return StyleVersion }, | |
| 133 | "join": strings.Join, | |
| 98 | "styleVersion": func() string { return StyleVersion }, | |
| 99 | "join": strings.Join, | |
| 134 | 100 | // paragraphs splits plain text on blank lines for safe rich display. |
| 135 | 101 | "paragraphs": func(s string) []string { |
| 136 | 102 | var out []string |
internal/web/widths_test.go +4 −2
| @@ -31,8 +31,10 @@ func TestListPagesAreWide(t *testing.T) { | ||
| 31 | 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">`) { | |
| 35 | t.Error("explore.html does not use one-line rows") | |
| 34 | for _, name := range []string{"explore.html", "owner.html"} { | |
| 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 | 39 | // Settings pages carry a section column: every section id has a link |
| 38 | 40 | // in the column, and the page is wide with the narrow grid. |