1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "slices"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/a-h/templ"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// maxRepos bounds the repositories one page lists.
23const maxRepos = 1000
24
25// pageSize is the page size asked from the commit lists: the largest the use
26// case accepts for them.
27const pageSize = 100
28
29// listRepos reads the repositories the visitor may see, ordered by name.
30// Every page of ListRepos scans the whole repository list and reads the HEAD
31// of each repository of the page, so the list is asked in one page of up to
32// maxRepos: one scan in the common case, whatever the cap of the use case is
33// (a lower one only means more pages, followed by their token).
34func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
35 var repos []ports.RepoView
36 token := ""
37 for len(repos) < maxRepos {
38 page, err := s.cfg.Repos.ListPage(ctx, principal(ctx), maxRepos, token)
39 if err != nil {
40 return nil, err
41 }
42 for _, repo := range page.Repos {
43 view, err := s.cfg.Repos.View(ctx, repo)
44 if err != nil {
45 return nil, err
46 }
47 repos = append(repos, view)
48 }
49 if token = page.NextPageToken; token == "" {
50 break
51 }
52 }
53 return repos, nil
54}
55
56// homeSorts are the orders the home list offers, in order. The query also
57// accepts "desc" and "branch", which the page does not link to.
58var homeSorts = []struct{ key, label string }{
59 {"activity", "last activity"},
60 {"name", "name"},
61}
62
63// homeKeys are the keys of ?sort= that sortRepos knows.
64var homeKeys = []string{"activity", "name", "desc", "branch"}
65
66// sortRepos orders repos by the column key; ties keep the order by name.
67func sortRepos(repos []ports.RepoView, key string, desc bool) {
68 less := func(a, b ports.RepoView) bool {
69 switch key {
70 case "desc":
71 return strings.ToLower(a.Description) < strings.ToLower(b.Description)
72 case "branch":
73 return a.DefaultBranch < b.DefaultBranch
74 case "activity":
75 return repoActiveAt(a).Before(repoActiveAt(b))
76 }
77 return a.Name.String() < b.Name.String()
78 }
79 sort.SliceStable(repos, func(i, j int) bool {
80 if desc {
81 return less(repos[j], repos[i])
82 }
83 return less(repos[i], repos[j])
84 })
85}
86
87// home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first
88// by default.
89func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
90 repos, err := s.listRepos(ctx)
91 if err != nil {
92 return nil, 0, err
93 }
94 key, desc := "activity", true
95 for _, k := range homeKeys {
96 if r.URL.Query().Get("sort") == k {
97 key, desc = k, k == "activity"
98 }
99 }
100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
101 desc = d == "desc"
102 }
103 sortRepos(repos, key, desc)
104
105 now := s.cfg.Now()
106 p := HomePage{Sort: key, Desc: desc}
107 for _, c := range homeSorts {
108 sl := SortLink{Label: c.label}
109 next := "asc"
110 if c.key == "activity" {
111 next = "desc"
112 }
113 if c.key == key {
114 sl.Mark, sl.State = "▲", "ascending, select for descending"
115 next = "desc"
116 if desc {
117 sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc"
118 }
119 }
120 sl.Href = "/?sort=" + c.key + "&dir=" + next
121 p.Sorts = append(p.Sorts, sl)
122 }
123 p.Rows = s.homeRows(ctx, repos, now)
124 l := s.base()
125 if n, show := s.inboxCount(ctx); show {
126 l.Inbox = &InboxButton{Href: inboxPath, Count: n}
127 }
128 l.Title = s.instance
129 l.Live, l.LiveURL = true, liveURL("home", "")
130 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
131 return Home(l, p), http.StatusOK, nil
132}
133
134// homeRecent is how many commits a row of the home list shows.
135const homeRecent = 5
136
137// homeWorkers bounds the repositories whose commits and run are read at once.
138const homeWorkers = 8
139
140// homeRows builds the rows of repos, in order: each repository costs a read
141// of its commits and of its latest run, so they are read a few at a time. A
142// repository whose reads fail still has its row.
143func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
144 rows := make([]HomeRow, len(repos))
145 var (
146 sem = make(chan struct{}, homeWorkers)
147 wg sync.WaitGroup
148 )
149 for i, repo := range repos {
150 sem <- struct{}{}
151 wg.Add(1)
152 go func() {
153 defer func() { <-sem; wg.Done() }()
154 rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
155 }()
156 }
157 wg.Wait()
158 return rows
159}
160
161// homeRowOf is the row of a repository with what the visitor may read of it:
162// the newest commits of its default branch and the state of its latest run.
163func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
164 row := homeRow(r, now, cursor, SignedIn(ctx))
165 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
166 row.NoCommits = r.PushedAt.IsZero()
167 return row
168 }
169 var head string
170 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
171 if head != "" {
172 row.CI = s.homeCI(ctx, r, head)
173 row.Stats = s.homeStats(ctx, r, head, now)
174 }
175 return row
176}
177
178// homeCommits reads the newest commits of the default branch. It reports an
179// empty branch; a failed read is logged and gives neither commits nor that
180// note, since the row does not depend on them.
181func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
182 // By its full name, so that a tag of the same name is not read.
183 page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
184 switch {
185 case errors.Is(err, domain.ErrNotFound):
186 return nil, "", true
187 case err != nil:
188 log := zerowrap.FromCtx(ctx)
189 log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
190 return nil, "", false
191 }
192 for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
193 rows = append(rows, commitRow(r.Name.String(), c, now))
194 }
195 if len(page.Commits) > 0 {
196 head = page.Commits[0].ID.String()
197 }
198 return rows, head, len(rows) == 0
199}
200
201// homeStats are the figures of a row; nil when the read fails (logged), since
202// the row does not depend on them.
203func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats {
204 id, err := domain.ParseObjectID(head)
205 if err != nil {
206 return nil
207 }
208 st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now)
209 if err != nil {
210 if !errors.Is(err, domain.ErrNotFound) {
211 log := zerowrap.FromCtx(ctx)
212 log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page")
213 }
214 return nil
215 }
216 hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
217 if st.Partial {
218 hs.Recent += "+" // a lower bound: the walk stopped at its cap
219 }
220 if t := st.LatestTag; t != nil {
221 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
222 if !t.CommitID.IsZero() {
223 // By its commit: a branch of the same name would win over the
224 // tag in a tree path.
225 hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String())
226 }
227 }
228 return hs
229}
230
231// homeCI is the state of the CI on the head of the default branch, as the
232// caller of the request sees it: the combined checks of that commit, read by
233// its id so the cost does not grow with the CI history. nil when the server
234// runs no CI, the caller cannot read it, the commit has no check, or the read
235// fails (checksFor logs it).
236func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
237 checks := s.checksFor(ctx, r.Name, head)
238 if len(checks) == 0 {
239 return nil
240 }
241 sum := checksSummary(r.Name, head, checks)
242 ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
243 if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
244 ci.Href = runHref(r.Name.String(), run) // one run: straight to it
245 }
246 return ci
247}
248
249// ciLabel is the word next to the mark of the CI state on the home page.
250func ciLabel(kind string) string {
251 switch kind {
252 case "ok":
253 return "passing"
254 case "bad":
255 return "failing"
256 }
257 return "running"
258}
259
260// homeRow is the row of a repository in the home list, without its commits
261// and run. The private badge is for a signed-in visitor: an anonymous one
262// never gets a private repository.
263func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
264 name := r.Name.String()
265 row := HomeRow{
266 ID: "repo-" + name, Name: name, Href: repoHref(name),
267 Desc: r.Description, Topics: r.Topics, Cursor: cursor,
268 Private: signed && r.Visibility == domain.VisibilityPrivate,
269 }
270 if at := repoActiveAt(r); !at.IsZero() {
271 row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
272 }
273 return row
274}
275
276// repoCmds are the repositories as targets of the ":" command line, but the
277// one being viewed.
278func repoCmds(repos []ports.RepoView, except string) []CmdItem {
279 var out []CmdItem
280 for _, r := range repos {
281 if name := r.Name.String(); name != except {
282 out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
283 }
284 }
285 return out
286}
287
288// repoActiveAt falls back for repositories without recorded activity.
289func repoActiveAt(r ports.RepoView) time.Time {
290 if !r.ActiveAt.IsZero() {
291 return r.ActiveAt
292 }
293 if r.PushedAt.After(r.CreatedAt) {
294 return r.PushedAt
295 }
296 return r.CreatedAt
297}