1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "slices"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15)
16
17// maxRefs bounds the refs one page reads. A repository with more has its
18// lists cut, and the pages say so.
19const maxRefs = 1000
20
21// repoData is what the pages of one repository start from: its metadata and
22// its refs, read once per request.
23type repoData struct {
24 repo ports.RepoView
25 branches []domain.Ref
26 tags []domain.Ref
27 // cut is true when the repository has more refs than maxRefs.
28 cut bool
29 // head is the branch the repository opens on: its default branch, or the
30 // first one when the default does not exist. nil for an empty repository.
31 head *domain.Ref
32}
33
34// repoView reads a repository as the caller of the request may see it, with
35// the Git state of its summary. A missing or private repository is
36// domain.ErrNotFound.
37func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {
38 repo, err := s.repoRecord(ctx, name)
39 if err != nil {
40 return ports.RepoView{}, err
41 }
42 return s.cfg.Repos.View(ctx, repo)
43}
44
45// repoRecord reads the repository record only, without Git: its name,
46// visibility and settings (the defaults when it has none).
47//
48//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
49func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
50 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
51 if err != nil {
52 return domain.Repo{}, err
53 }
54 if repo.Settings.IsZero() {
55 repo.Settings = domain.DefaultRepoSettings()
56 }
57 return repo, nil
58}
59
60// loadRepo reads a repository and its refs. A missing, private or invalidly
61// named repository is a domain.ErrNotFound / invalid name error, which the
62// caller turns into the 404 page.
63func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) {
64 name, err := domain.ParseRepoName(rawName)
65 if err != nil {
66 return nil, err
67 }
68 view, err := s.repoView(ctx, name)
69 if err != nil {
70 return nil, err
71 }
72 d := &repoData{repo: view}
73 // Each page of ListRefs runs the whole for-each-ref again: the refs are
74 // asked in one page of up to maxRefs (the use case lowers it to its cap,
75 // and the token then leads to the rest).
76 token, n := "", 0
77 for {
78 page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{
79 Kinds: []domain.RefKind{domain.RefKindBranch, domain.RefKindTag},
80 PageSize: maxRefs, PageToken: token,
81 })
82 if err != nil {
83 return nil, err
84 }
85 for _, r := range page.Refs {
86 switch r.Kind {
87 case domain.RefKindBranch:
88 d.branches = append(d.branches, r)
89 case domain.RefKindTag:
90 d.tags = append(d.tags, r)
91 }
92 n++
93 }
94 if token = page.NextPageToken; token == "" {
95 break
96 }
97 if n >= maxRefs {
98 d.cut = true
99 break
100 }
101 }
102 for i := range d.branches {
103 if d.branches[i].ShortName == d.repo.DefaultBranch {
104 d.head = &d.branches[i]
105 }
106 }
107 if d.head == nil && len(d.branches) > 0 {
108 d.head = &d.branches[0]
109 }
110 return d, nil
111}
112
113// allRefs is the branches then the tags.
114func (d *repoData) allRefs() []domain.Ref {
115 return append(append([]domain.Ref(nil), d.branches...), d.tags...)
116}
117
118// name is the repository name.
119func (d *repoData) name() string { return d.repo.Name.String() }
120
121// openMRCount is the number of open merge requests shown on the tab, empty
122// when there are none or they cannot be counted (the tab still works).
123func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
124 n, err := s.cfg.MergeRequests.CountOpen(ctx, principal(ctx), name)
125 if err != nil || n == 0 {
126 return ""
127 }
128 return itoa(n)
129}
130
131// layout is the Layout of the pages of a repository: the sections, the ref
132// switcher on the head ref and the ":" targets. The caller sets the title,
133// the crumbs, the status and the rail.
134func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135 l := s.base()
136 l.Repo = d.name()
137 def := d.repo.DefaultBranch
138 if d.head != nil {
139 def = d.head.ShortName
140 l.Ref, l.RefPath = def, def
141 l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String())
142 l.setFinder(l.SHA, def)
143 }
144 mk := func(key, label, href, id string) Section {
145 return Section{Key: key, Label: label, Href: href, Current: section == id}
146 }
147 l.Sections = []Section{
148 mk("s", "Summary", repoHref(d.name()), "summary"),
149 mk("t", "Files", repoHref(d.name(), "tree", def), "tree"),
150 mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),
151 mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
152 }
153 if s.cfg.MergeRequests != nil {
154 mrs := mk("m", "Merge requests", mrBase(d.name()), "merge-requests")
155 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156 l.Sections = append(l.Sections, mrs)
157 }
158 l.Sections = s.withActions(l.Sections, d.name(), section)
159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160
161 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
162 for _, sec := range l.Sections {
163 l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: strings.ToLower(sec.Label), Href: sec.Href})
164 }
165 if l.Ref != "" {
166 for _, b := range d.branches {
167 it := RefItem{Name: b.ShortName, Href: repoHref(d.name(), "tree", b.ShortName), Current: b.ShortName == d.head.ShortName}
168 l.Heads = append(l.Heads, it)
169 l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
170 }
171 for _, t := range d.tags {
172 it := RefItem{Name: t.ShortName, Href: repoHref(d.name(), "tree", t.ShortName)}
173 l.Tags = append(l.Tags, it)
174 l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
175 }
176 }
177 // Other repositories, when the list is reachable; the page does not
178 // depend on it.
179 if repos, err := s.listRepos(ctx); err == nil {
180 l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...)
181 }
182 return l
183}
184
185// withActions adds the Actions tab when the server runs CI.
186func (s *Server) withActions(sections []Section, repo, current string) []Section {
187 if s.cfg.Runs == nil {
188 return sections
189 }
190 return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"})
191}
192
193// cloneURL is the HTTPS clone URL of a repository.
194func (s *Server) cloneURL(name string) string {
195 return s.cfg.PublicURL + "/" + name + ".git"
196}
197
198// refCommit is what a ref shows: the commit it designates.
199type refCommit struct {
200 short, subject, when, whenAt string
201 at int64 // unix time, to sort tags
202}
203
204// commitsOf reads the head commit of each ref, a few at a time. A ref whose
205// commit cannot be read (a tag on a tree, say) has no entry.
206func (s *Server) commitsOf(ctx context.Context, repo domain.RepoName, refs []domain.Ref) map[string]refCommit {
207 const workers = 8
208 var (
209 mu sync.Mutex
210 out = make(map[string]refCommit, len(refs))
211 sem = make(chan struct{}, workers)
212 wg sync.WaitGroup
213 )
214 now := s.cfg.Now()
215 for _, r := range refs {
216 if r.CommitID.IsZero() {
217 continue
218 }
219 id := r.CommitID.String()
220 mu.Lock()
221 _, done := out[id]
222 if !done {
223 out[id] = refCommit{} // reserved: one lookup per commit
224 }
225 mu.Unlock()
226 if done {
227 continue
228 }
229 sem <- struct{}{}
230 wg.Add(1)
231 go func() {
232 defer func() { <-sem; wg.Done() }()
233 // By id, not by name: a branch and a tag may share a name.
234 res, err := s.commitList(ctx, repo, id, "", 1, "")
235 var rc refCommit
236 if err == nil && len(res.Commits) == 1 {
237 row := commitRow(repo.String(), res.Commits[0], now)
238 rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt
239 rc.at = unixSeconds(res.Commits[0].Committer.Time)
240 }
241 mu.Lock()
242 out[id] = rc
243 mu.Unlock()
244 }()
245 }
246 wg.Wait()
247 return out
248}
249
250// whenOf is the relative and the machine form of a time; both are "" when
251// the time is unknown (the zero time).
252func whenOf(now time.Time, t time.Time) (when, at string) {
253 if t.IsZero() {
254 return "", ""
255 }
256 return ago(now, t), rfc3339(t)
257}
258
259// unixSeconds is the Unix time of t in seconds, 0 for the zero time.
260func unixSeconds(t time.Time) int64 {
261 if t.IsZero() {
262 return 0
263 }
264 return t.Unix()
265}
266
267// commitRow is the row every list of commits starts from: the short id, the
268// subject, the author and the date of the committer, all fit to display. The
269// caller adds what is specific to its list.
270func commitRow(repo string, c domain.Commit, now time.Time) CommitRow {
271 row := CommitRow{
272 Short: short(c.ID.String()), Subject: visibleText(c.Summary),
273 CHref: repoHref(repo, "commit", c.ID.String()),
274 Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1,
275 }
276 row.When, row.WhenAt = whenOf(now, c.Committer.Time)
277 return row
278}
279
280// namesAt maps commit ids to the names of the refs that designate them.
281func namesAt(refs []domain.Ref) map[string][]string {
282 m := map[string][]string{}
283 for _, r := range refs {
284 m[r.CommitID.String()] = append(m[r.CommitID.String()], r.ShortName)
285 }
286 for _, v := range m {
287 sort.Strings(v)
288 }
289 return m
290}
291
292// emptyState is the page of a repository without a branch.
293func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) {
294 l := s.repoLayout(ctx, d, "summary")
295 l.Title = d.name() + " · " + s.instance
296 l.Sections = nil
297 l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
298 branch := d.repo.DefaultBranch
299 return l, StatePage{
300 Code: "empty", Title: d.name() + " is empty",
301 Lines: []string{"no commits yet. push a branch to create one."},
302 Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{
303 "git remote add origin " + s.cloneURL(d.name()),
304 "git push -u origin " + branch,
305 }},
306 Back: []Link{{Label: "home", Href: "/"}},
307 }
308}