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 row.Release = s.summaryRelease(ctx, r.Name, now)
166 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
167 row.NoCommits = r.PushedAt.IsZero()
168 return row
169 }
170 var head string
171 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
172 if head != "" {
173 row.License = s.repositoryLicense(ctx, r.Name, head)
174 row.CI = s.homeCI(ctx, r, head)
175 row.Stats = s.homeStats(ctx, r, head, now)
176 }
177 return row
178}
179
180// homeCommits reads the newest commits of the default branch. It reports an
181// empty branch; a failed read is logged and gives neither commits nor that
182// note, since the row does not depend on them.
183func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
184 // By its full name, so that a tag of the same name is not read.
185 page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
186 switch {
187 case errors.Is(err, domain.ErrNotFound):
188 return nil, "", true
189 case err != nil:
190 log := zerowrap.FromCtx(ctx)
191 log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
192 return nil, "", false
193 }
194 for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
195 rows = append(rows, commitRow(r.Name.String(), c, now))
196 }
197 if len(page.Commits) > 0 {
198 head = page.Commits[0].ID.String()
199 }
200 return rows, head, len(rows) == 0
201}
202
203// homeStats are the figures of a row; nil when the read fails (logged), since
204// the row does not depend on them.
205func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats {
206 id, err := domain.ParseObjectID(head)
207 if err != nil {
208 return nil
209 }
210 st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now)
211 if err != nil {
212 if !errors.Is(err, domain.ErrNotFound) {
213 log := zerowrap.FromCtx(ctx)
214 log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page")
215 }
216 return nil
217 }
218 hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
219 if st.Partial {
220 hs.Recent += "+" // a lower bound: the walk stopped at its cap
221 }
222 if t := st.LatestTag; t != nil {
223 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
224 if !t.CommitID.IsZero() {
225 // By its commit: a branch of the same name would win over the
226 // tag in a tree path.
227 hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String())
228 }
229 }
230 return hs
231}
232
233// homeCI is the state of the CI on the head of the default branch, as the
234// caller of the request sees it: the combined checks of that commit, read by
235// its id so the cost does not grow with the CI history. nil when the server
236// runs no CI, the caller cannot read it, the commit has no check, or the read
237// fails (checksFor logs it).
238func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
239 checks := s.checksFor(ctx, r.Name, head)
240 if len(checks) == 0 {
241 return nil
242 }
243 sum := checksSummary(r.Name, head, checks)
244 ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
245 if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
246 ci.Href = runHref(r.Name.String(), run) // one run: straight to it
247 }
248 return ci
249}
250
251// ciLabel is the word next to the mark of the CI state on the home page.
252func ciLabel(kind string) string {
253 switch kind {
254 case "ok":
255 return "passing"
256 case "bad":
257 return "failing"
258 }
259 return "running"
260}
261
262// homeRow is the row of a repository in the home list, without its commits
263// and run. The private badge is for a signed-in visitor: an anonymous one
264// never gets a private repository.
265func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
266 name := r.Name.String()
267 row := HomeRow{
268 ID: "repo-" + name, Name: name, Href: repoHref(name),
269 Desc: r.Description, Topics: r.Topics, Cursor: cursor,
270 Private: signed && r.Visibility == domain.VisibilityPrivate,
271 }
272 if at := repoActiveAt(r); !at.IsZero() {
273 row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
274 }
275 return row
276}
277
278// repoCmds are the repositories as targets of the ":" command line, but the
279// one being viewed.
280func repoCmds(repos []ports.RepoView, except string) []CmdItem {
281 var out []CmdItem
282 for _, r := range repos {
283 if name := r.Name.String(); name != except {
284 out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
285 }
286 }
287 return out
288}
289
290// repoActiveAt falls back for repositories without recorded activity.
291func repoActiveAt(r ports.RepoView) time.Time {
292 if !r.ActiveAt.IsZero() {
293 return r.ActiveAt
294 }
295 if r.PushedAt.After(r.CreatedAt) {
296 return r.PushedAt
297 }
298 return r.CreatedAt
299}