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.
|
f09ef39e
Keenan Nemetz
15d ago
|
1 | package 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 | ||
| 7 | import ( | |
|
4581690d
Keenan Nemetz
13d ago
|
8 | "fmt" |
|
f09ef39e
Keenan Nemetz
15d ago
|
9 | "html/template" |
|
4581690d
Keenan Nemetz
13d ago
|
10 | "io" |
|
f09ef39e
Keenan Nemetz
15d ago
|
11 | "net/http" |
| 12 | "sort" | |
|
a196e404
Keenan Nemetz
11d ago
|
13 | "time" |
|
f09ef39e
Keenan Nemetz
15d ago
|
14 | |
|
d37d301f
Keenan Nemetz
15h ago
|
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" | |
|
f09ef39e
Keenan Nemetz
15d ago
|
21 | ) |
| 22 | ||
|
4581690d
Keenan Nemetz
13d ago
|
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. | |
| 27 | const maxReadmeBytes = 1 << 20 | |
| 28 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
29 | type languageShare struct { |
| 30 | Name string | |
| 31 | Bytes int64 | |
| 32 | Percent int | |
| 33 | } | |
| 34 | ||
|
a32a859a
Keenan Nemetz
4d ago
|
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. | |
|
f09ef39e
Keenan Nemetz
15d ago
|
38 | type contributorView struct { |
|
a32a859a
Keenan Nemetz
4d ago
|
39 | Name string |
| 40 | Merged int | |
| 41 | Fixed int | |
| 42 | Reported int | |
| 43 | Reviews int | |
| 44 | Bot bool | |
| 45 | } | |
| 46 | ||
| 47 | func (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. | |
| 52 | type 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. | |
| 61 | func (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 | ||
| 77 | func countLabel(n int, one, many string) string { | |
| 78 | if n == 1 { | |
| 79 | return "1 " + one | |
| 80 | } | |
| 81 | return fmt.Sprintf("%d %s", n, many) | |
|
f09ef39e
Keenan Nemetz
15d ago
|
82 | } |
| 83 | ||
|
a32a859a
Keenan Nemetz
4d ago
|
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. | |
| 87 | const overviewContributorLimit = 8 | |
| 88 | ||
|
a196e404
Keenan Nemetz
11d ago
|
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. | |
| 92 | type 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. | |
| 103 | const overviewSideLimit = 5 | |
| 104 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
105 | func (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 | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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) | |
|
f09ef39e
Keenan Nemetz
15d ago
|
115 | |
|
a32a859a
Keenan Nemetz
4d ago
|
116 | memo := memoOf(r) |
| 117 | commits, languages, tags := w.overviewSummaries(memo, m, commit) | |
| 118 | contributors := w.overviewContributors(memo, m) | |
|
f09ef39e
Keenan Nemetz
15d ago
|
119 | |
| 120 | var recentRuns []*runs.Run | |
| 121 | if w.Runs != nil { | |
| 122 | recentRuns, _ = w.Runs.Recent(m.Name, 5) | |
| 123 | } | |
| 124 | ||
|
a32a859a
Keenan Nemetz
4d ago
|
125 | recentIssues, recentRequests := w.overviewRecent(memo, m) |
|
a196e404
Keenan Nemetz
11d ago
|
126 | |
|
796ccd90
Keenan Nemetz
13d ago
|
127 | readme, readmeName := w.readmeAt(m, ref, commit) |
|
f09ef39e
Keenan Nemetz
15d ago
|
128 | |
| 129 | w.render(rw, "overview", struct { | |
| 130 | Page | |
|
a196e404
Keenan Nemetz
11d ago
|
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 | |
|
f09ef39e
Keenan Nemetz
15d ago
|
143 | }{ |
|
a196e404
Keenan Nemetz
11d ago
|
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, | |
|
f09ef39e
Keenan Nemetz
15d ago
|
157 | }, 0) |
| 158 | } | |
| 159 | ||
|
2a8f7cef
Keenan Nemetz
9d ago
|
160 | // overviewSummaries returns the parts of the overview that describe the default |
|
a32a859a
Keenan Nemetz
4d ago
|
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. | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
165 | func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) ( |
|
a32a859a
Keenan Nemetz
4d ago
|
166 | commits []git.Commit, languages []languageShare, tags []git.Tag) { |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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 | |
|
a32a859a
Keenan Nemetz
4d ago
|
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. | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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 | } | |
|
a32a859a
Keenan Nemetz
4d ago
|
201 | return commits, languages, tags |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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 | } | |
|
a32a859a
Keenan Nemetz
4d ago
|
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. | |
| 223 | func (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] | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
276 | } |
|
a32a859a
Keenan Nemetz
4d ago
|
277 | return list |
|
2a8f7cef
Keenan Nemetz
9d ago
|
278 | } |
| 279 | ||
|
a196e404
Keenan Nemetz
11d ago
|
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. | |
|
5d440e37
Keenan Nemetz
10d ago
|
284 | func (w *Web) overviewRecent(memo *requestMemo, m *repo.Meta) (issues, requests []overviewEntity) { |
|
a196e404
Keenan Nemetz
11d ago
|
285 | if w.Index != nil { |
|
73ec800b
Keenan Nemetz
10d ago
|
286 | if err := w.ensureIndex(memo, m); err == nil { |
|
2a8f7cef
Keenan Nemetz
9d ago
|
287 | if rows, err := w.Index.Issues(m.ID, index.Filter{State: "open", Limit: overviewSideLimit}); err == nil { |
|
a196e404
Keenan Nemetz
11d ago
|
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 | } | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
295 | if rows, err := w.Index.Requests(m.ID, index.RequestFilter{State: "open", Limit: overviewSideLimit}); err == nil { |
|
b739915c
Keenan Nemetz
10d ago
|
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) | |
|
a196e404
Keenan Nemetz
11d ago
|
304 | } |
| 305 | } | |
|
5d440e37
Keenan Nemetz
10d ago
|
306 | if list, err := w.requestsAt(memo, m); err == nil { |
|
a196e404
Keenan Nemetz
11d ago
|
307 | for _, req := range list { |
| 308 | if req.Open() { | |
| 309 | requests = append(requests, overviewEntity{ | |
| 310 | ID: req.ID, Title: req.Title, Author: req.Author, | |
|
b739915c
Keenan Nemetz
10d ago
|
311 | Created: req.Created, |
|
a196e404
Keenan Nemetz
11d ago
|
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. | |
| 322 | func 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 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
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. | |
|
796ccd90
Keenan Nemetz
13d ago
|
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. | |
| 339 | func (w *Web) readmeAt(m *repo.Meta, ref, commit string) (template.HTML, string) { | |
|
f09ef39e
Keenan Nemetz
15d ago
|
340 | if commit == "" { |
| 341 | return "", "" | |
| 342 | } | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
343 | key := commitKey(m.Dir, commit) + "\x00" + ref |
| 344 | if v, ok := w.readmes.get(key); ok { | |
| 345 | return v.html, v.name | |
| 346 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
347 | for _, name := range []string{"README.md", "readme.md", "Readme.md", "README.markdown"} { |
|
4581690d
Keenan Nemetz
13d ago
|
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. | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
357 | note := template.HTML(fmt.Sprintf( |
|
ed03b3d1
Keenan Nemetz
3h ago
|
358 | `<p class="muted">%s is %s, past the %s this page renders. <a href="/%s/blob/%s/%s">View the source</a>.</p>`, |
|
4581690d
Keenan Nemetz
13d ago
|
359 | template.HTMLEscapeString(name), format.Bytes(size), format.Bytes(maxReadmeBytes), |
| 360 | template.HTMLEscapeString(m.Name), template.HTMLEscapeString(ref), | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
361 | template.HTMLEscapeString(name))) |
| 362 | w.readmes.put(key, readmeAnswer{html: note, name: name}) | |
| 363 | return note, name | |
|
4581690d
Keenan Nemetz
13d ago
|
364 | } |
| 365 | data, err := io.ReadAll(io.LimitReader(stream, maxReadmeBytes)) | |
| 366 | stream.Close() | |
| 367 | if err != nil { | |
| 368 | continue | |
|
f09ef39e
Keenan Nemetz
15d ago
|
369 | } |
|
4581690d
Keenan Nemetz
13d ago
|
370 | doc := treeDoc{Repo: m.Name, Ref: ref, Path: name} |
|
2a8f7cef
Keenan Nemetz
9d ago
|
371 | rendered := w.renderTreeMarkdown(doc, string(data)) |
| 372 | w.readmes.put(key, readmeAnswer{html: rendered, name: name}) | |
| 373 | return rendered, name | |
|
f09ef39e
Keenan Nemetz
15d ago
|
374 | } |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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{}) | |
|
f09ef39e
Keenan Nemetz
15d ago
|
378 | return "", "" |
| 379 | } | |
| 380 | ||
|
2a8f7cef
Keenan Nemetz
9d ago
|
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. | |
| 385 | func languageShares(groups []lang.Share) []languageShare { | |
|
f09ef39e
Keenan Nemetz
15d ago
|
386 | var total int64 |
|
2a8f7cef
Keenan Nemetz
9d ago
|
387 | for _, g := range groups { |
| 388 | total += g.Bytes | |
|
f09ef39e
Keenan Nemetz
15d ago
|
389 | } |
| 390 | if total == 0 { | |
| 391 | return nil | |
| 392 | } | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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 { | |
|
f09ef39e
Keenan Nemetz
15d ago
|
397 | pct = 1 |
| 398 | } | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
399 | list = append(list, languageShare{Name: g.Name, Bytes: g.Bytes, Percent: pct}) |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 | } |