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 "connectrpc.com/connect"
14 "google.golang.org/protobuf/types/known/timestamppb"
15
16 pb "bnema.dev/ermite-api/gen/ermite"
17)
18
19// maxRefs bounds the refs one page reads. A repository with more has its
20// lists cut, and the pages say so.
21const maxRefs = 1000
22
23// repoData is what the pages of one repository start from: its metadata and
24// its refs, read once per request.
25type repoData struct {
26 repo *pb.Repo
27 branches []*pb.Ref
28 tags []*pb.Ref
29 // cut is true when the repository has more refs than maxRefs.
30 cut bool
31 // head is the branch the repository opens on: its default branch, or the
32 // first one when the default does not exist. nil for an empty repository.
33 head *pb.Ref
34}
35
36// loadRepo reads a repository and its refs. A missing, private or invalidly
37// named repository is a not_found / invalid_argument error of the API, which
38// the caller turns into the 404 page.
39func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
40 res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
41 if err != nil {
42 return nil, err
43 }
44 d := &repoData{repo: res.Msg.GetRepo()}
45 token, n := "", 0
46 for {
47 page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{
48 Repo: name, PageSize: pageSize, PageToken: token,
49 Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG},
50 }))
51 if err != nil {
52 return nil, err
53 }
54 for _, r := range page.Msg.GetRefs() {
55 switch r.GetKind() {
56 case pb.RefKind_REF_KIND_BRANCH:
57 d.branches = append(d.branches, r)
58 case pb.RefKind_REF_KIND_TAG:
59 d.tags = append(d.tags, r)
60 }
61 n++
62 }
63 if token = page.Msg.GetNextPageToken(); token == "" {
64 break
65 }
66 if n >= maxRefs {
67 d.cut = true
68 break
69 }
70 }
71 for _, b := range d.branches {
72 if b.GetShortName() == d.repo.GetDefaultBranch() {
73 d.head = b
74 }
75 }
76 if d.head == nil && len(d.branches) > 0 {
77 d.head = d.branches[0]
78 }
79 return d, nil
80}
81
82// allRefs is the branches then the tags.
83func (d *repoData) allRefs() []*pb.Ref {
84 return append(append([]*pb.Ref(nil), d.branches...), d.tags...)
85}
86
87// name is the repository name.
88func (d *repoData) name() string { return d.repo.GetName() }
89
90// layout is the Layout of the pages of a repository: the sections, the ref
91// switcher on the head ref and the ":" targets. The caller sets the title,
92// the crumbs, the status and the rail.
93func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
94 l := s.base()
95 l.Repo = d.name()
96 def := d.repo.GetDefaultBranch()
97 if d.head != nil {
98 def = d.head.GetShortName()
99 l.Ref, l.RefPath = def, def
100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101 l.setFinder(l.SHA, def)
102 }
103 mk := func(key, label, href, id string) Section {
104 return Section{Key: key, Label: label, Href: href, Current: section == id}
105 }
106 l.Sections = []Section{
107 mk("s", "Summary", repoHref(d.name()), "summary"),
108 mk("t", "Files", repoHref(d.name(), "tree", def), "tree"),
109 mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),
110 mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
111 }
112 if s.cfg.MergeRequests != nil {
113 l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
114 }
115 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
116
117 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
118 for _, sec := range l.Sections {
119 l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: strings.ToLower(sec.Label), Href: sec.Href})
120 }
121 if l.Ref != "" {
122 for _, b := range d.branches {
123 it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head}
124 l.Heads = append(l.Heads, it)
125 l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
126 }
127 for _, t := range d.tags {
128 it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())}
129 l.Tags = append(l.Tags, it)
130 l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
131 }
132 }
133 // Other repositories, when the list is reachable; the page does not
134 // depend on it.
135 if repos, err := s.listRepos(ctx); err == nil {
136 l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...)
137 }
138 return l
139}
140
141// cloneURL is the HTTPS clone URL of a repository.
142func (s *Server) cloneURL(name string) string {
143 return s.cfg.PublicURL + "/" + name + ".git"
144}
145
146// refCommit is what a ref shows: the commit it designates.
147type refCommit struct {
148 short, subject, when, whenAt string
149 at int64 // unix time, to sort tags
150}
151
152// commitsOf reads the head commit of each ref, a few at a time. A ref whose
153// commit cannot be read (a tag on a tree, say) has no entry.
154func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit {
155 const workers = 8
156 var (
157 mu sync.Mutex
158 out = make(map[string]refCommit, len(refs))
159 sem = make(chan struct{}, workers)
160 wg sync.WaitGroup
161 )
162 now := s.cfg.Now()
163 for _, r := range refs {
164 id := r.GetCommitId()
165 if id == "" {
166 continue
167 }
168 mu.Lock()
169 _, done := out[id]
170 if !done {
171 out[id] = refCommit{} // reserved: one lookup per commit
172 }
173 mu.Unlock()
174 if done {
175 continue
176 }
177 sem <- struct{}{}
178 wg.Add(1)
179 go func() {
180 defer func() { <-sem; wg.Done() }()
181 // By id, not by name: a branch and a tag may share a name.
182 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))
183 var rc refCommit
184 if err == nil && len(res.Msg.GetCommits()) == 1 {
185 row := commitRow(repo, res.Msg.GetCommits()[0], now)
186 rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt
187 rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds()
188 }
189 mu.Lock()
190 out[id] = rc
191 mu.Unlock()
192 }()
193 }
194 wg.Wait()
195 return out
196}
197
198// whenOf is the relative and the machine form of a time; both are "" when
199// the time is unknown.
200func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) {
201 if !ts.IsValid() {
202 return "", ""
203 }
204 return ago(now, ts.AsTime()), rfc3339(ts.AsTime())
205}
206
207// commitRow is the row every list of commits starts from: the short id, the
208// subject, the author and the date of the committer, all fit to display. The
209// caller adds what is specific to its list.
210func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow {
211 row := CommitRow{
212 Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
213 CHref: repoHref(repo, "commit", c.GetId()),
214 Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
215 }
216 row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime())
217 return row
218}
219
220// namesAt maps commit ids to the names of the refs that designate them.
221func namesAt(refs []*pb.Ref) map[string][]string {
222 m := map[string][]string{}
223 for _, r := range refs {
224 m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName())
225 }
226 for _, v := range m {
227 sort.Strings(v)
228 }
229 return m
230}
231
232// emptyState is the page of a repository without a branch.
233func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) {
234 l := s.repoLayout(ctx, d, "summary")
235 l.Title = d.name() + " · " + s.instance
236 l.Sections = nil
237 l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
238 branch := d.repo.GetDefaultBranch()
239 return l, StatePage{
240 Code: "empty", Title: d.name() + " is empty",
241 Lines: []string{"no commits yet. push a branch to create one."},
242 Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{
243 "git remote add origin " + s.cloneURL(d.name()),
244 "git push -u origin " + branch,
245 }},
246 Back: []Link{{Label: "home", Href: "/"}},
247 }
248}