vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 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 ( |
| 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. |
| 27 | const maxReadmeBytes = 1 << 20 |
| 28 | |
| 29 | type 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. |
| 38 | type contributorView struct { |
| 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) |
| 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. |
| 87 | const 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. |
| 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 | |
| 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 |
| 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. |
| 165 | func (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. |
| 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] |
| 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. |
| 284 | func (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. |
| 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 | |
| 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. |
| 339 | func (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. |
| 385 | func 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 |