Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 24m ago 4b8c9e1d
history blame raw 13.3 KiB
1package web
2
3// The repository overview: the page a visitor lands on before they have chosen
4// a file or an issue. It answers "what is this, who works on it, and what
5// happened lately" from git and the queue, with no stored counters to go stale.
6
7import (
8 "fmt"
9 "html/template"
10 "io"
11 "net/http"
12 "sort"
13 "time"
14
15 "rodent.software/vole/ci/runs"
16 "rodent.software/vole/core/git"
17 "rodent.software/vole/forge/index"
18 "rodent.software/vole/forge/repo"
19 "rodent.software/vole/internal/format"
20 "rodent.software/vole/internal/lang"
21)
22
23// maxReadmeBytes is the largest README rendered on the overview. A file past it
24// is not a README but a payload, and the page says so instead of rendering it:
25// the markdown renderer and the DOM both scale with the input, and neither is
26// worth spending on a file nobody reads.
27const maxReadmeBytes = 1 << 20
28
29type languageShare struct {
30 Name string
31 Bytes int64
32 Percent int
33}
34
35// contributorView is one account in the overview's short list. The counts are
36// the repository's own, so the same account can rank differently in another
37// repository.
38type contributorView struct {
39 Name string
40 Merged int
41 Fixed int
42 Reported int
43 Reviews int
44 Bot bool
45}
46
47func (c contributorView) total() int { return c.Merged + c.Fixed + c.Reported + c.Reviews }
48
49// contributorStat is one icon beside an account's name. The label is the whole
50// sentence the icon stands for, shown as a tooltip and read by assistive tech;
51// a zero count is left out rather than drawn as an empty mark.
52type contributorStat struct {
53 Icon string
54 Count int
55 Label string
56}
57
58// Stats returns the counts that are not zero, in the order the section explains
59// them. The labels name the outcome, not the record behind it, and each count is
60// pluralised on its own so "1 issue reported" does not read like a schema dump.
61func (c contributorView) Stats() []contributorStat {
62 all := []contributorStat{
63 {Icon: "git-merge", Count: c.Merged, Label: countLabel(c.Merged, "change merged", "changes merged")},
64 {Icon: "circle-check", Count: c.Fixed, Label: countLabel(c.Fixed, "issue fixed", "issues fixed")},
65 {Icon: "circle-dot", Count: c.Reported, Label: countLabel(c.Reported, "issue reported", "issues reported")},
66 {Icon: "check", Count: c.Reviews, Label: countLabel(c.Reviews, "review", "reviews")},
67 }
68 out := all[:0]
69 for _, stat := range all {
70 if stat.Count > 0 {
71 out = append(out, stat)
72 }
73 }
74 return out
75}
76
77func countLabel(n int, one, many string) string {
78 if n == 1 {
79 return "1 " + one
80 }
81 return fmt.Sprintf("%d %s", n, many)
82}
83
84// overviewContributorLimit is how many accounts the sidebar names. The list is
85// a pointer to the work, not a directory of everybody who ever touched the
86// repository.
87const overviewContributorLimit = 8
88
89// overviewEntity is the small shape the overview's issue and change sections
90// need: enough to link, title, name the author and say how old it is. The list
91// pages carry richer rows; the sidebar says only what fits beside a README.
92type overviewEntity struct {
93 ID string
94 Title string
95 Author string
96 Created time.Time
97 Number int
98}
99
100// overviewSideLimit is how many open issues and change requests the overview
101// shows. Like the commit and run sections, it is a pointer to the full list, not
102// a replacement for it.
103const overviewSideLimit = 5
104
105func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) {
106 m, ok := w.open(rw, r)
107 if !ok {
108 return
109 }
110 g := m.Git()
111 ref := m.DefaultBranch
112 // A loose branch is a file read. RefCommit still falls back to rev-parse for
113 // a packed ref, and the answer is what the index's summary is checked against.
114 commit, _ := g.RefCommit("refs/heads/" + ref)
115
116 memo := memoOf(r)
117 commits, languages, tags := w.overviewSummaries(memo, m, commit)
118 contributors := w.overviewContributors(memo, m)
119
120 var recentRuns []*runs.Run
121 if w.Runs != nil {
122 recentRuns, _ = w.Runs.Recent(m.Name, 5)
123 }
124
125 recentIssues, recentRequests := w.overviewRecent(memo, m)
126
127 readme, readmeName := w.readmeAt(m, ref, commit)
128
129 w.render(rw, "overview", struct {
130 Page
131 Name string
132 Ref string
133 Commit string
134 Readme template.HTML
135 ReadmeName string
136 Languages []languageShare
137 Commits []git.Commit
138 RecentIssues []overviewEntity
139 RecentRequests []overviewEntity
140 Runs []*runs.Run
141 Tags []git.Tag
142 Contributors []contributorView
143 }{
144 Page: w.page(r, m.Name),
145 Name: m.Name,
146 Ref: ref,
147 Commit: commit,
148 Readme: readme,
149 ReadmeName: readmeName,
150 Languages: languages,
151 Commits: commits,
152 RecentIssues: recentIssues,
153 RecentRequests: recentRequests,
154 Runs: recentRuns,
155 Tags: tags,
156 Contributors: contributors,
157 }, 0)
158}
159
160// overviewSummaries returns the parts of the overview that describe the default
161// branch: its newest commits, its languages, and the repository's tags. When the
162// index has the summary for this exact tip they are rows; when it does not (no
163// index, or a tip the push hook has not recorded) they are read from git, which
164// is the fallback that keeps the page correct.
165func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) (
166 commits []git.Commit, languages []languageShare, tags []git.Tag) {
167 g := m.Git()
168 if w.Index != nil {
169 if err := w.ensureIndex(memo, m); err == nil {
170 if e, err := w.Index.Get(m.Name); err == nil && e.HeadSHA == commit {
171 if list, err := w.Index.RecentCommits(e.ID, 5); err == nil {
172 commits = make([]git.Commit, 0, len(list))
173 for _, c := range list {
174 commits = append(commits, git.Commit{SHA: c.SHA, Subject: c.Subject, Author: c.Author, When: c.When})
175 }
176 }
177 if shares, err := w.Index.Languages(e.ID); err == nil {
178 languages = languageShares(shares)
179 }
180 if list, err := w.Index.Tags(e.ID, 5); err == nil {
181 tags = make([]git.Tag, 0, len(list))
182 for _, t := range list {
183 tags = append(tags, git.Tag{
184 Name: t.Name, OID: t.OID, Commit: t.Commit, Annotated: t.Annotated,
185 Tagger: t.Tagger, When: t.When, Subject: t.Subject,
186 })
187 }
188 }
189 // A repository with commits has at least one of each; an empty
190 // section means the summary was written without it (a tree walk
191 // that failed), so read that part from git rather than leave the
192 // page blank until the next push.
193 if commit != "" {
194 if len(commits) == 0 {
195 commits, _ = g.Log(commit, 5)
196 }
197 if len(languages) == 0 {
198 languages = w.languages(g, commit)
199 }
200 }
201 return commits, languages, tags
202 }
203 }
204 }
205 if commit != "" {
206 commits, _ = g.Log(commit, 5)
207 languages = w.languages(g, commit)
208 }
209 if list, err := g.Tags(); err == nil {
210 if len(list) > 5 {
211 list = list[:5]
212 }
213 tags = list
214 }
215 return commits, languages, tags
216}
217
218// overviewContributors ranks the accounts whose work the metadata branch
219// records: changes that merged, issues those changes fixed, issues the account
220// reported that were fixed, and reviews that named the merged head. Commit
221// authors are deliberately absent: a git name is text somebody typed, not an
222// account, and there is no field that joins the two.
223func (w *Web) overviewContributors(memo *requestMemo, m *repo.Meta) []contributorView {
224 if w.Index == nil {
225 return nil
226 }
227 if err := w.ensureIndex(memo, m); err != nil {
228 return nil
229 }
230 sums, err := w.Index.ContributorSummaries(m.ID)
231 if err != nil {
232 return nil
233 }
234 list := make([]contributorView, 0, len(sums))
235 for name, sum := range sums {
236 view := contributorView{
237 Name: name, Merged: sum.Merged, Fixed: sum.Fixed,
238 Reported: sum.Reported, Reviews: sum.Reviews,
239 }
240 if w.Ident != nil {
241 // A name in metadata that the roster does not know is not an
242 // account. It can be left behind by a rewrite or a hand-edited
243 // branch, and the page should not present it as a person.
244 acct, err := w.Ident.Find(name)
245 if err != nil {
246 continue
247 }
248 view.Name = acct.Name
249 view.Bot = acct.Bot()
250 }
251 if view.total() == 0 {
252 continue
253 }
254 list = append(list, view)
255 }
256 sort.SliceStable(list, func(i, j int) bool {
257 if a, b := list[i].total(), list[j].total(); a != b {
258 return a > b
259 }
260 if list[i].Merged != list[j].Merged {
261 return list[i].Merged > list[j].Merged
262 }
263 if list[i].Fixed != list[j].Fixed {
264 return list[i].Fixed > list[j].Fixed
265 }
266 if list[i].Reported != list[j].Reported {
267 return list[i].Reported > list[j].Reported
268 }
269 if list[i].Reviews != list[j].Reviews {
270 return list[i].Reviews > list[j].Reviews
271 }
272 return list[i].Name < list[j].Name
273 })
274 if len(list) > overviewContributorLimit {
275 list = list[:overviewContributorLimit]
276 }
277 return list
278}
279
280// overviewRecent returns the newest open issues and change requests for the
281// overview sidebar. Closed and merged records are history and already have a
282// home in their own lists; the overview is a map of what is still being worked
283// on. A failed read leaves an empty section rather than failing the README page.
284func (w *Web) overviewRecent(memo *requestMemo, m *repo.Meta) (issues, requests []overviewEntity) {
285 if w.Index != nil {
286 if err := w.ensureIndex(memo, m); err == nil {
287 if rows, err := w.Index.Issues(m.ID, index.Filter{State: "open", Limit: overviewSideLimit}); err == nil {
288 for _, row := range rows {
289 issues = append(issues, overviewEntity{
290 ID: row.ID, Title: row.Title, Author: row.Author,
291 Created: row.Created, Number: row.Number,
292 })
293 }
294 }
295 if rows, err := w.Index.Requests(m.ID, index.RequestFilter{State: "open", Limit: overviewSideLimit}); err == nil {
296 for _, row := range rows {
297 requests = append(requests, overviewEntity{
298 ID: row.ID, Title: row.Title, Author: row.Author,
299 Created: row.Created, Number: row.Number,
300 })
301 }
302 }
303 return newestOverview(issues), newestOverview(requests)
304 }
305 }
306 if list, err := w.requestsAt(memo, m); err == nil {
307 for _, req := range list {
308 if req.Open() {
309 requests = append(requests, overviewEntity{
310 ID: req.ID, Title: req.Title, Author: req.Author,
311 Created: req.Created,
312 })
313 }
314 }
315 }
316 return newestOverview(issues), newestOverview(requests)
317}
318
319// newestOverview puts the newest entity first and keeps only what the sidebar
320// shows. Created is the human fact the row prints, so it decides the order;
321// the id breaks a tie without depending on map iteration or a ULID's spelling.
322func newestOverview(list []overviewEntity) []overviewEntity {
323 sort.SliceStable(list, func(i, j int) bool {
324 if !list[i].Created.Equal(list[j].Created) {
325 return list[i].Created.After(list[j].Created)
326 }
327 return list[i].ID > list[j].ID
328 })
329 if len(list) > overviewSideLimit {
330 list = list[:overviewSideLimit]
331 }
332 return list
333}
334
335// readmeAt returns the rendered README and the name it was found under. The name
336// is shown above the body: a rendered file with no filename is harder to trust.
337// The ref travels with it because a relative link in the README has to resolve to
338// a URL under the branch the reader is looking at.
339func (w *Web) readmeAt(m *repo.Meta, ref, commit string) (template.HTML, string) {
340 if commit == "" {
341 return "", ""
342 }
343 key := commitKey(m.Dir, commit) + "\x00" + ref
344 if v, ok := w.readmes.get(key); ok {
345 return v.html, v.name
346 }
347 for _, name := range []string{"README.md", "readme.md", "Readme.md", "README.markdown"} {
348 stream, err := m.Git().OpenBlobAt(commit, name)
349 if err != nil {
350 continue
351 }
352 size := stream.Size
353 if size > maxReadmeBytes {
354 stream.Close()
355 // Name the file and the size: a README block that is silently absent
356 // looks like a bug in the forge rather than a decision about the file.
357 note := template.HTML(fmt.Sprintf(
358 `<p class="muted">%s is %s, past the %s this page renders. <a href="/%s/blob/%s/%s">View the source</a>.</p>`,
359 template.HTMLEscapeString(name), format.Bytes(size), format.Bytes(maxReadmeBytes),
360 template.HTMLEscapeString(m.Name), template.HTMLEscapeString(ref),
361 template.HTMLEscapeString(name)))
362 w.readmes.put(key, readmeAnswer{html: note, name: name})
363 return note, name
364 }
365 data, err := io.ReadAll(io.LimitReader(stream, maxReadmeBytes))
366 stream.Close()
367 if err != nil {
368 continue
369 }
370 doc := treeDoc{Repo: m.Name, Ref: ref, Path: name}
371 rendered := w.renderTreeMarkdown(doc, string(data))
372 w.readmes.put(key, readmeAnswer{html: rendered, name: name})
373 return rendered, name
374 }
375 // A repository without a README would otherwise look for four names on every
376 // visit; remember that the answer is none.
377 w.readmes.put(key, readmeAnswer{})
378 return "", ""
379}
380
381// languageShares turns stored or freshly read language groups into the bar the
382// overview draws: the six largest by bytes and everything else folded together.
383// It is a rough map, not a linguist: the point is to show what a repository is
384// mostly made of, not to classify every generated file.
385func languageShares(groups []lang.Share) []languageShare {
386 var total int64
387 for _, g := range groups {
388 total += g.Bytes
389 }
390 if total == 0 {
391 return nil
392 }
393 list := make([]languageShare, 0, len(groups))
394 for _, g := range groups {
395 pct := int(g.Bytes * 100 / total)
396 if pct == 0 && g.Bytes > 0 {
397 pct = 1
398 }
399 list = append(list, languageShare{Name: g.Name, Bytes: g.Bytes, Percent: pct})
400 }
401 if len(list) > 6 {
402 other := languageShare{Name: "Other"}
403 for _, l := range list[6:] {
404 other.Bytes += l.Bytes
405 other.Percent += l.Percent
406 }
407 list = append(list[:6], other)
408 }
409 return list
410}
411