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 {"push", "last push"},
60 {"name", "name"},
61}
62
63// homeKeys are the keys of ?sort= that sortRepos knows.
64var homeKeys = []string{"push", "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 "push":
75 return a.PushedAt.Before(b.PushedAt)
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 push 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 := "push", true
95 for _, k := range homeKeys {
96 if r.URL.Query().Get("sort") == k {
97 key, desc = k, k == "push"
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{}
107 for _, c := range homeSorts {
108 sl := SortLink{Label: c.label}
109 next := "asc"
110 if c.key == "push" {
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
125 l := s.base()
126 l.Title = s.instance
127 l.Live, l.LiveURL = true, liveURL("home", "")
128 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
129 return Home(l, p), http.StatusOK, nil
130}
131
132// homeRecent is how many commits a row of the home list shows.
133const homeRecent = 5
134
135// homeWorkers bounds the repositories whose commits and run are read at once.
136const homeWorkers = 8
137
138// homeRows builds the rows of repos, in order: each repository costs a read
139// of its commits and of its latest run, so they are read a few at a time. A
140// repository whose reads fail still has its row.
141func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
142 rows := make([]HomeRow, len(repos))
143 var (
144 sem = make(chan struct{}, homeWorkers)
145 wg sync.WaitGroup
146 )
147 for i, repo := range repos {
148 sem <- struct{}{}
149 wg.Add(1)
150 go func() {
151 defer func() { <-sem; wg.Done() }()
152 rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
153 }()
154 }
155 wg.Wait()
156 return rows
157}
158
159// homeRowOf is the row of a repository with what the visitor may read of it:
160// the newest commits of its default branch and the state of its latest run.
161func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
162 row := homeRow(r, now, cursor, SignedIn(ctx))
163 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
164 row.NoCommits = r.PushedAt.IsZero()
165 return row
166 }
167 var head string
168 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169 if head != "" {
170 row.CI = s.homeCI(ctx, r, head)
171 }
172 return row
173}
174
175// homeCommits reads the newest commits of the default branch. It reports an
176// empty branch; a failed read is logged and gives neither commits nor that
177// note, since the row does not depend on them.
178func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
179 // By its full name, so that a tag of the same name is not read.
180 page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
181 switch {
182 case errors.Is(err, domain.ErrNotFound):
183 return nil, "", true
184 case err != nil:
185 log := zerowrap.FromCtx(ctx)
186 log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
187 return nil, "", false
188 }
189 for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
190 rows = append(rows, commitRow(r.Name.String(), c, now))
191 }
192 if len(page.Commits) > 0 {
193 head = page.Commits[0].ID.String()
194 }
195 return rows, head, len(rows) == 0
196}
197
198// homeCI is the state of the CI on the head of the default branch, as the
199// caller of the request sees it: the combined checks of that commit, read by
200// its id so the cost does not grow with the CI history. nil when the server
201// runs no CI, the caller cannot read it, the commit has no check, or the read
202// fails (checksFor logs it).
203func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
204 checks := s.checksFor(ctx, r.Name, head)
205 if len(checks) == 0 {
206 return nil
207 }
208 sum := checksSummary(r.Name, head, checks)
209 ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
210 if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
211 ci.Href = runHref(r.Name.String(), run) // one run: straight to it
212 }
213 return ci
214}
215
216// ciLabel is the word next to the mark of the CI state on the home page.
217func ciLabel(kind string) string {
218 switch kind {
219 case "ok":
220 return "passing"
221 case "bad":
222 return "failing"
223 }
224 return "running"
225}
226
227// homeRow is the row of a repository in the home list, without its commits
228// and run. The private badge is for a signed-in visitor: an anonymous one
229// never gets a private repository.
230func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
231 name := r.Name.String()
232 row := HomeRow{
233 ID: "repo-" + name, Name: name, Href: repoHref(name),
234 Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor,
235 Private: signed && r.Visibility == domain.VisibilityPrivate,
236 }
237 if !r.PushedAt.IsZero() {
238 row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
239 }
240 return row
241}
242
243// repoCmds are the repositories as targets of the ":" command line, but the
244// one being viewed.
245func repoCmds(repos []ports.RepoView, except string) []CmdItem {
246 var out []CmdItem
247 for _, r := range repos {
248 if name := r.Name.String(); name != except {
249 out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
250 }
251 }
252 return out
253}