internal/httpd/web.go
1912 lines · 59946 bytes
1package httpd
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "hash/fnv"
10 "io"
11 "log"
12 "math"
13 "os"
14 "path/filepath"
15
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "html/template"
19 "net/http"
20 "net/url"
21 "path"
22 "regexp"
23 "sort"
24 "strconv"
25 "strings"
26 "time"
27
28 "github.com/alecthomas/chroma/v2/formatters/html"
29 "github.com/alecthomas/chroma/v2/lexers"
30 "github.com/alecthomas/chroma/v2/styles"
31 "github.com/microcosm-cc/bluemonday"
32 "github.com/niklasfasching/go-org/org"
33 "github.com/yuin/goldmark"
34 highlighting "github.com/yuin/goldmark-highlighting/v2"
35 "github.com/yuin/goldmark/extension"
36 "github.com/yuin/goldmark/parser"
37
38 "gitbay.org/gitbay/internal/autolink"
39 "gitbay.org/gitbay/internal/control"
40 "gitbay.org/gitbay/internal/gitutil"
41 "gitbay.org/gitbay/internal/sig"
42 "gitbay.org/gitbay/internal/store"
43 "gitbay.org/gitbay/internal/web"
44)
45
46const maxRenderBytes = 1 << 20 // largest blob rendered inline
47
48func (s *Server) render(w http.ResponseWriter, page string, data any) {
49 var buf bytes.Buffer
50 if err := web.Render(&buf, page, data); err != nil {
51 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
52 return
53 }
54 w.Header().Set("Content-Type", "text/html; charset=utf-8")
55 buf.WriteTo(w)
56}
57
58// siteName is the instance's display name: the operator's [web] title,
59// or the site host when they have not set one.
60func (s *Server) siteName() string {
61 if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
62 return t
63 }
64 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
65 return strings.TrimSuffix(h, "/")
66}
67
68// stylesheetETag is the hash of what stylesheet serves, computed once:
69// a browser revalidates with If-None-Match and gets a 304 until a deploy
70// changes the bytes (#132).
71var stylesheetETag = func() string {
72 h := sha256.New()
73 h.Write(web.StyleCSS)
74 h.Write(chromaCSS)
75 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
76}()
77
78func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
79 w.Header().Set("ETag", stylesheetETag)
80 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
81 if r.Header.Get("If-None-Match") == stylesheetETag {
82 w.WriteHeader(http.StatusNotModified)
83 return
84 }
85 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS)
87 w.Write(chromaCSS)
88}
89
90func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
91 w.Header().Set("Content-Type", "image/svg+xml")
92 w.Write(web.FaviconSVG)
93}
94
95// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
96// so the CSP's default-src 'self' covers it — no font CDN.
97func (s *Server) font(w http.ResponseWriter, r *http.Request) {
98 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
99 if err != nil {
100 http.NotFound(w, r)
101 return
102 }
103 w.Header().Set("Content-Type", "font/woff2")
104 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
105 w.Write(data)
106}
107
108// notFound renders the designed 404 page with a 404 status. Falls back to
109// the stock plain-text response if the template fails.
110func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
111 var buf bytes.Buffer
112 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
113 http.NotFound(w, r)
114 return
115 }
116 w.Header().Set("Content-Type", "text/html; charset=utf-8")
117 w.WriteHeader(http.StatusNotFound)
118 buf.WriteTo(w)
119}
120
121// describedRepo pairs a repo with the listing metadata: description,
122// topics, license, and last-updated date.
123type describedRepo struct {
124 store.Repo
125 Desc string
126 Topics []string
127 License string
128 Updated string
129}
130
131// Archived flattens the settings flag so the reporow partial can read the
132// same field name from a describedRepo and from a profile's repo row.
133func (d describedRepo) Archived() bool { return d.Settings.Archived }
134
135func (s *Server) describeAll(repos []store.Repo) []describedRepo {
136 var out []describedRepo
137 for _, r := range repos {
138 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
139 d := describedRepo{
140 Repo: r,
141 Desc: gitutil.ReadDescription(dir),
142 License: control.DetectLicense(dir, r.DefaultBranch),
143 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
144 }
145 d.Topics, _ = s.st.ListTopics(r.ID)
146 out = append(out, d)
147 }
148 return out
149}
150
151// index is the homepage: a dashboard for logged-in users, a landing page
152// for everyone else. The full public listing lives at /explore.
153func (s *Server) index(w http.ResponseWriter, r *http.Request) {
154 if s.cfg.Web.Mode == "accounts" {
155 if viewer := s.viewer(r); viewer.ID != 0 {
156 s.dashboard(w, r, viewer)
157 return
158 }
159 }
160 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162 s.render(w, "landing.html", struct {
163 basePage
164 Host string
165 Accounts bool
166 Signup bool
167 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
168 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
169}
170
171func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172 pinned, _ := s.st.PinnedRepos(viewer.ID)
173 var visible []store.Repo
174 for _, rp := range pinned {
175 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
176 if policy.CanRead(viewer, rp, grant) {
177 visible = append(visible, rp)
178 }
179 }
180 mrs, _ := s.st.DashboardMRs(viewer.ID)
181 issues, _ := s.st.DashboardIssues(viewer.ID)
182 reviews, _ := s.st.ReviewQueue(viewer.ID)
183 assigned, _ := s.st.AssignedIssues(viewer.ID)
184 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
185 s.render(w, "dashboard.html", struct {
186 basePage
187 Pinned []store.Repo
188 Reviews []store.DashboardItem
189 Assigned []store.DashboardItem
190 MRs []store.DashboardItem
191 Issues []store.DashboardItem
192 Feed []feedLine
193 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
194}
195
196func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
197 repos, err := s.st.ListPublicRepos()
198 if err != nil {
199 http.Error(w, "internal error", http.StatusInternalServerError)
200 return
201 }
202 var viewer store.User
203 if s.cfg.Web.Mode == "accounts" {
204 viewer = s.viewer(r)
205 }
206 q := strings.TrimSpace(r.URL.Query().Get("q"))
207 s.render(w, "explore.html", struct {
208 basePage
209 Query string
210 Repos []describedRepo
211 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
212}
213
214// privacy renders the privacy page: what the gitbay software does with
215// data, plus this instance's operator-provided notes.
216func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
217 s.render(w, "privacy.html", struct {
218 basePage
219 Host string
220 Notice string
221 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
222}
223
224// filterRepos keeps repos matching the query by the same rule `repo
225// search` uses. An empty query keeps everything.
226func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
227 if q == "" {
228 return repos
229 }
230 var out []describedRepo
231 for _, d := range repos {
232 if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
233 out = append(out, d)
234 }
235 }
236 return out
237}
238
239// repoPage is the shared context for repo-scoped pages.
240type repoPage struct {
241 basePage
242 Desc string
243 Repo store.Repo
244 Ref string
245 CloneURL string
246 Dir string
247 Tab string // active tab in the repo header
248 Topics []string
249 Pinned bool // by the viewer
250 Watch string // the viewer's watch state: watching, muted, or ""
251 HasWiki bool
252 Host string
253 Mirrors []mirrorLine // repo admins only
254 CanAdmin bool // gates the settings tab
255 // OpenIssues and OpenMRs are the counts on the header tabs.
256 OpenIssues int
257 OpenMRs int
258 // RepoHome asks the layout for the full header — description, topics,
259 // website, mirrors. Every other page gets identity and tabs only, so a
260 // repo describes itself once rather than on all twelve of its pages.
261 RepoHome bool
262}
263
264// mirrorLine is the admin-only mirror status shown in the repo header.
265// It carries no credentials: the stored URL is credential-free.
266type mirrorLine struct {
267 Direction string
268 URL string
269 Target string // URL without the scheme, for display
270 Synced string
271 Error string
272}
273
274// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
275// readable "2026-08-25 03:39 UTC".
276func syncedAt(ts string) string {
277 if len(ts) < 16 {
278 return ts
279 }
280 return ts[:10] + " " + ts[11:16] + " UTC"
281}
282
283// repoFor resolves the repo for a web request; false means 404 was sent.
284// Anonymous visitors see public repos only; in accounts mode a logged-in
285// viewer additionally sees repos their grants allow. Private and missing
286// repos are indistinguishable either way.
287func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
288 var repo store.Repo
289 var viewer store.User
290 if s.cfg.Web.Mode == "accounts" {
291 viewer = s.viewer(r)
292 }
293 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
294 ok := err == nil
295 grant := ""
296 if ok {
297 if viewer.ID != 0 {
298 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
299 }
300 ok = policyCanRead(viewer, repo, grant)
301 }
302 if !ok {
303 s.notFound(w, r)
304 return repoPage{}, false
305 }
306 if ref == "" {
307 ref = repo.DefaultBranch
308 }
309 topics, _ := s.st.ListTopics(repo.ID)
310 pinned, watch := false, ""
311 if viewer.ID != 0 {
312 pinned = s.st.IsPinned(viewer.ID, repo.ID)
313 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
314 }
315 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
316 var mirrors []mirrorLine
317 if canAdmin {
318 ms, _ := s.st.ListMirrors(repo.ID)
319 for _, m := range ms {
320 mirrors = append(mirrors, mirrorLine{
321 Direction: m.Direction,
322 URL: m.URL,
323 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
324 Synced: syncedAt(m.LastSync),
325 Error: m.LastError,
326 })
327 }
328 }
329 openIssues, openMRs := s.st.OpenCounts(repo.ID)
330 return repoPage{
331 basePage: s.baseFor(viewer),
332 CanAdmin: canAdmin,
333 Mirrors: mirrors,
334 Pinned: pinned,
335 Watch: watch,
336 HasWiki: s.hasWiki(repo),
337 Host: s.cfg.SiteHost(),
338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
339 Repo: repo,
340 Ref: ref,
341 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
342 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
343 Topics: topics,
344 OpenIssues: openIssues,
345 OpenMRs: openMRs,
346 }, true
347}
348
349type crumb struct {
350 Name string
351 URL string
352}
353
354// crumbs builds one crumb per path component. Every component but the
355// last is a directory and links to the tree; only the leaf is a page of
356// the given kind.
357func crumbs(p repoPage, kind, filePath string) []crumb {
358 var cs []crumb
359 parts := strings.Split(strings.Trim(filePath, "/"), "/")
360 acc := ""
361 for i, part := range parts {
362 if part == "" {
363 continue
364 }
365 acc = path.Join(acc, part)
366 k := "tree"
367 if i == len(parts)-1 {
368 k = kind
369 }
370 cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
371 }
372 return cs
373}
374
375// profileView is profile show's payload, shaped for the templates. The
376// repo rows carry the same names the reporow partial reads, so a profile
377// listing renders identically to explore's.
378// profileView is profile show's payload with the repository rows wrapped
379// so the reporow partial can reach them. The fields themselves are the
380// command's: a field it gains appears here without being re-declared.
381type profileView struct {
382 control.ProfileOut
383 Repos []profileRepoRow `json:"repos"`
384}
385
386// profileRepoRow is one repository row on a profile. The partial asks for
387// OwnerName, Name and Desc; the payload carries a path and a description.
388type profileRepoRow struct {
389 control.ProfileRepo
390}
391
392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394func (p profileRepoRow) Desc() string { return p.Description }
395
396// ownerPage renders /{owner} for users and orgs: the repositories the
397// viewer may see, org membership either direction. Owner names are not
398// secret (they are on every commit); repository visibility rules hold.
399func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
400 name := r.PathValue("owner")
401 var viewer store.User
402 if s.cfg.Web.Mode == "accounts" {
403 viewer = s.viewer(r)
404 }
405
406 // Everything on this page — membership, the repositories this viewer
407 // may see, the activity year — comes from profile show, so the page
408 // and the command cannot report different things.
409 var d profileView
410 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
411 switch {
412 case code == protocol.ExitNotFound:
413 s.notFound(w, r)
414 return
415 case code != protocol.ExitOK:
416 log.Printf("profile %s: %s", name, msg)
417 http.Error(w, "internal error", http.StatusInternalServerError)
418 return
419 }
420
421 counts := make(map[string]int, len(d.Activity))
422 for _, day := range d.Activity {
423 counts[day.Date] = day.Count
424 }
425 weeks, activityTotal := activityGrid(counts)
426
427 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
428 profile := store.Profile{Description: d.Description, Website: d.Website,
429 About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
430 s.render(w, "owner.html", struct {
431 basePage
432 Owner string
433 Kind string
434 Profile store.Profile
435 AboutHTML template.HTML
436 Repos []profileRepoRow
437 Members []control.ProfileMember
438 Orgs []control.ProfileMember
439 Activity []activityWeek
440 ActivityTotal int
441 Teams []teamView
442 CanAdmin bool
443 Notice string
444 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
445 d.Repos, d.Members, d.Orgs,
446 weeks, activityTotal, teams, canAdmin, s.takeFlash(w, r)})
447}
448
449func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
450 p, ok := s.repoFor(w, r, "")
451 if !ok {
452 return
453 }
454 p.Tab = "files"
455 p.RepoHome = true
456 s.renderTree(w, r, p, "")
457}
458
459func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
460 p, ok := s.repoFor(w, r, r.PathValue("ref"))
461 if !ok {
462 return
463 }
464 p.Tab = "files"
465 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
466}
467
468// treePage is shared by the populated and empty-repository renders: two
469// anonymous structs drifted apart once already.
470type treePage struct {
471 repoPage
472 Crumbs []crumb
473 Prefix string
474 DirPath string
475 RefKind string
476 Entries []gitutil.TreeEntry
477 Branches []gitutil.Ref
478 ReadmeName string
479 ReadmeHTML template.HTML
480 LastCommits map[string]namedCommit
481 Tip namedCommit
482 Facts repoFacts
483}
484
485func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
486 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
487 // Empty repo: render the page with no entries rather than 404.
488 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
489 return
490 }
491 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
492 if err != nil {
493 s.notFound(w, r)
494 return
495 }
496 // Directories first. git's tree order interleaves them with files, but
497 // a listing is scanned by shape before name. Stable, so each group
498 // keeps the ordering git gave it.
499 sort.SliceStable(entries, func(i, j int) bool {
500 return entries[i].Type == "tree" && entries[j].Type != "tree"
501 })
502 prefix := ""
503 if dirPath != "" {
504 prefix = dirPath + "/"
505 }
506
507 var readmeHTML template.HTML
508 readmeName := pickReadme(entries)
509 if readmeName != "" {
510 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
511 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
512 }
513 }
514
515 branches, _ := gitutil.Refs(p.Dir, "heads")
516 names := make([]string, 0, len(entries))
517 for _, e := range entries {
518 names = append(names, e.Name)
519 }
520 // The facts bar is about the repository, not this directory, so it is
521 // computed once at the root and left off subdirectory listings.
522 var facts repoFacts
523 if dirPath == "" {
524 facts = s.factsFor(p)
525 }
526 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
527 readmeName, readmeHTML,
528 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
529 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
530}
531
532func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
533 p, ok := s.repoFor(w, r, r.PathValue("ref"))
534 if !ok {
535 return
536 }
537 p.Tab = "files"
538 filePath := strings.Trim(r.PathValue("path"), "/")
539 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
540 if err != nil {
541 s.notFound(w, r)
542 return
543 }
544 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
545 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
546
547 var codeHTML template.HTML
548 if !binary && !image {
549 codeHTML = highlight(filePath, data)
550 }
551 // Markdown and org render like a README, with the source one click
552 // away; ?view=source shows the text instead.
553 renderable := false
554 switch path.Ext(strings.ToLower(filePath)) {
555 case ".md", ".markdown", ".org":
556 renderable = !binary
557 }
558 var renderedHTML template.HTML
559 rendered := renderable && r.URL.Query().Get("view") != "source"
560 if rendered {
561 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
562 }
563 cs := crumbs(p, "blob", filePath)
564 base := ""
565 if len(cs) > 0 {
566 base = cs[len(cs)-1].Name
567 cs = cs[:len(cs)-1]
568 }
569 branches, _ := gitutil.Refs(p.Dir, "heads")
570 lines := 0
571 if !binary && !image && len(data) > 0 {
572 lines = bytes.Count(data, []byte("\n"))
573 if data[len(data)-1] != '\n' {
574 lines++
575 }
576 }
577 // The file listing leads with the last commit now, so the facts about
578 // the file itself are reported here instead.
579 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
580 s.render(w, "blob.html", struct {
581 repoPage
582 Crumbs []crumb
583 Base string
584 Path string
585 DirPath string
586 RefKind string
587 Binary bool
588 Image bool
589 Size int
590 Lines int
591 Exec bool
592 Symlink bool
593 Branches []gitutil.Ref
594 CodeHTML template.HTML
595 Renderable bool // markdown or org: the toggle is offered
596 Rendered bool // this response shows the rendering
597 RenderedHTML template.HTML
598 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
599 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
600}
601
602// releases lists tag-anchored releases with notes and assets.
603func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
604 p, ok := s.repoFor(w, r, "")
605 if !ok {
606 return
607 }
608 p.Tab = "releases"
609 rels, err := s.st.ListReleases(p.Repo.ID)
610 if err != nil {
611 http.Error(w, "internal error", http.StatusInternalServerError)
612 return
613 }
614 md := s.ugcFor(r, p.Repo)
615 type relView struct {
616 store.Release
617 NotesHTML template.HTML
618 }
619 var views []relView
620 for _, rel := range rels {
621 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
622 }
623 // Tags without a release yet are what a create form can offer.
624 released := map[string]bool{}
625 for _, rel := range rels {
626 released[rel.Tag] = true
627 }
628 var freeTags []string
629 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
630 for _, tg := range tags {
631 if !released[tg.Name] {
632 freeTags = append(freeTags, tg.Name)
633 }
634 }
635 }
636 s.render(w, "releases.html", struct {
637 repoPage
638 Releases []relView
639 FreeTags []string
640 CanWrite bool
641 Notice string
642 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
643}
644
645// releaseAsset streams one uploaded asset. Tags containing '/' are not
646// reachable here (single path segment); SSH download always works.
647func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
648 p, ok := s.repoFor(w, r, "")
649 if !ok {
650 return
651 }
652 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
653 if err != nil {
654 s.notFound(w, r)
655 return
656 }
657 name := r.PathValue("name")
658 found := false
659 for _, a := range rel.Assets {
660 if a.Name == name {
661 found = true
662 }
663 }
664 if !found {
665 s.notFound(w, r)
666 return
667 }
668 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
669 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
670 if err != nil {
671 s.notFound(w, r)
672 return
673 }
674 defer f.Close()
675 w.Header().Set("Content-Type", "application/octet-stream")
676 w.Header().Set("X-Content-Type-Options", "nosniff")
677 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
678 if fi, err := f.Stat(); err == nil {
679 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
680 }
681 io.Copy(w, f)
682}
683
684// milestones lists a repo's milestones with progress.
685func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
686 p, ok := s.repoFor(w, r, "")
687 if !ok {
688 return
689 }
690 p.Tab = "issues"
691 state := r.URL.Query().Get("state")
692 if state != "closed" && state != "all" {
693 state = "open"
694 }
695 ms, err := s.st.ListMilestones(p.Repo.ID, state)
696 if err != nil {
697 http.Error(w, "internal error", http.StatusInternalServerError)
698 return
699 }
700 type msView struct {
701 store.Milestone
702 Percent int
703 }
704 var views []msView
705 for _, m := range ms {
706 v := msView{Milestone: m}
707 if total := m.OpenItems + m.ClosedItems; total > 0 {
708 v.Percent = m.ClosedItems * 100 / total
709 }
710 views = append(views, v)
711 }
712 s.render(w, "milestones.html", struct {
713 repoPage
714 State string
715 Milestones []msView
716 }{p, state, views})
717}
718
719// search runs a bounded literal git grep over the repo's default branch.
720func (s *Server) search(w http.ResponseWriter, r *http.Request) {
721 p, ok := s.repoFor(w, r, "")
722 if !ok {
723 return
724 }
725 p.Tab = "search"
726 q := strings.TrimSpace(r.URL.Query().Get("q"))
727 type matchView struct {
728 Path string
729 Line int
730 TextHTML template.HTML
731 }
732 var matches []matchView
733 var queryErr string
734 if q != "" {
735 if len(q) < 2 || len(q) > 200 {
736 queryErr = "query must be 2 to 200 characters"
737 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
738 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
739 if err != nil {
740 http.Error(w, "internal error", http.StatusInternalServerError)
741 return
742 }
743 for _, m := range raw {
744 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
745 }
746 }
747 }
748 s.render(w, "search.html", struct {
749 repoPage
750 Query string
751 QueryErr string
752 Matches []matchView
753 Capped bool
754 }{p, q, queryErr, matches, len(matches) == 200})
755}
756
757// markMatch escapes a matched line and wraps case-insensitive occurrences
758// of the query in <mark>.
759func markMatch(text, q string) template.HTML {
760 lower, lq := strings.ToLower(text), strings.ToLower(q)
761 var b strings.Builder
762 pos := 0
763 for {
764 i := strings.Index(lower[pos:], lq)
765 if i < 0 {
766 break
767 }
768 i += pos
769 b.WriteString(template.HTMLEscapeString(text[pos:i]))
770 b.WriteString("<mark>")
771 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
772 b.WriteString("</mark>")
773 pos = i + len(q)
774 }
775 b.WriteString(template.HTMLEscapeString(text[pos:]))
776 return template.HTML(b.String())
777}
778
779func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
780 p, ok := s.repoFor(w, r, r.PathValue("ref"))
781 if !ok {
782 return
783 }
784 p.Tab = "files"
785 filePath := strings.Trim(r.PathValue("path"), "/")
786
787 // Blame is a control command; the web renders what it returns rather
788 // than shelling out to git itself, so all three surfaces agree.
789 page := 1
790 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
791 page = n
792 }
793 from := (page-1)*control.BlameSpan + 1
794
795 var out struct {
796 From int `json:"from"`
797 To int `json:"to"`
798 TotalLines int `json:"total_lines"`
799 Hunks []struct {
800 SHA string `json:"sha"`
801 AuthorName string `json:"author_name"`
802 AuthorEmail string `json:"author_email"`
803 Date string `json:"date"`
804 Summary string `json:"summary"`
805 StartLine int `json:"start_line"`
806 Lines []string `json:"lines"`
807 } `json:"hunks"`
808 }
809 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
810 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
811 var viewer store.User
812 if s.cfg.Web.Mode == "accounts" {
813 viewer = s.viewer(r)
814 }
815 msg, ok := s.runControlInto(viewer, argv, &out)
816
817 // A binary or empty file is a refusal, not a 404: the page still
818 // renders and says why there is nothing to attribute.
819 binary := false
820 if !ok {
821 if strings.Contains(msg, "is binary") {
822 binary = true
823 } else {
824 s.notFound(w, r)
825 return
826 }
827 }
828
829 type hunkView struct {
830 gitutil.BlameHunk
831 ShortSHA string
832 Date string
833 Sig sigView
834 Numbered []numberedLine
835 }
836 var hunks []hunkView
837 sigs := map[string]sigView{}
838 for _, h := range out.Hunks {
839 v, seen := sigs[h.SHA]
840 if !seen {
841 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
842 sigs[h.SHA] = v
843 }
844 date := h.Date
845 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
846 date = t.Format("2006-01-02")
847 }
848 hv := hunkView{
849 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
850 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
851 StartLine: h.StartLine, Lines: h.Lines},
852 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
853 }
854 for i, l := range h.Lines {
855 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
856 }
857 hunks = append(hunks, hv)
858 }
859
860 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
861 if pages == 0 {
862 pages = 1
863 }
864 if page > pages {
865 page = pages
866 }
867
868 cs := crumbs(p, "blame", filePath)
869 base := ""
870 if len(cs) > 0 {
871 base = cs[len(cs)-1].Name
872 cs = cs[:len(cs)-1]
873 }
874 s.render(w, "blame.html", struct {
875 repoPage
876 Crumbs []crumb
877 Base string
878 Path string
879 Binary bool
880 Hunks []hunkView
881 Page, Pages int
882 }{p, cs, base, filePath, binary, hunks, page, pages})
883}
884
885type numberedLine struct {
886 N int
887 Text string
888}
889
890// chromaFormatter emits class-based markup (no inline colors), so the
891// stylesheet can swap palettes with the color scheme.
892var chromaFormatter = html.New(html.WithClasses(true),
893 html.WithLineNumbers(true), html.LineNumbersInTable(false),
894 html.WithLinkableLineNumbers(true, "L"))
895
896func highlight(filePath string, data []byte) template.HTML {
897 lexer := lexers.Match(filePath)
898 if lexer == nil {
899 lexer = lexers.Fallback
900 }
901 iterator, err := lexer.Tokenise(nil, string(data))
902 if err != nil {
903 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
904 }
905 var buf bytes.Buffer
906 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
907 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
908 }
909 return template.HTML(buf.String())
910}
911
912// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
913// The light one cannot be left unscoped: the two palettes do not name the
914// same token set, and every token github-dark omits would keep its
915// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
916// Scoped, an unnamed token inherits the wrapper's colour instead, which is
917// readable in both. The site's --code-bg stays the background either way.
918// lightStyle and darkStyle are chosen on measured contrast against the
919// grounds code actually sits on here — page, code block, and the diff
920// tints. friendly, the chroma default, put 61 token/ground pairs under
921// 4.5:1; xcode puts one.
922const (
923 lightStyle = "xcode"
924 darkStyle = "github-dark"
925)
926
927var chromaCSS = func() []byte {
928 var buf bytes.Buffer
929 buf.WriteString("@media (prefers-color-scheme: light) {\n")
930 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
931 // xcode's NameAttribute is its one token under 4.5:1 against the diff
932 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
933 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
934 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
935 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
936 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
937 // Line numbers take the site's own gutter colour in both schemes. Left
938 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
939 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
940 // latter is a formatter fallback, not a style entry, so no palette test
941 // can see it.
942 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
943 return buf.Bytes()
944}()
945
946func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
947 p, ok := s.repoFor(w, r, r.PathValue("ref"))
948 if !ok {
949 return
950 }
951 filePath := strings.Trim(r.PathValue("path"), "/")
952 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
953 if err != nil {
954 s.notFound(w, r)
955 return
956 }
957 // Serve inert: never let repo content execute in the forge's origin.
958 // Images get their real type so <img> works under nosniff; SVG script
959 // is dead on arrival because the instance CSP is script-src 'none'.
960 ct := "text/plain; charset=utf-8"
961 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
962 ct = t
963 }
964 w.Header().Set("Content-Type", ct)
965 w.Header().Set("X-Content-Type-Options", "nosniff")
966 w.Write(data)
967}
968
969// imageTypes are the formats raw serves with a real content type and blob
970// pages preview inline.
971var imageTypes = map[string]string{
972 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
973 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
974 ".svg": "image/svg+xml", ".ico": "image/x-icon",
975}
976
977// readmeRank orders competing README files: richer renderers win.
978var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
979
980// pickReadme returns the best README-ish blob in a tree listing: any file
981// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
982// we can render richly.
983func pickReadme(entries []gitutil.TreeEntry) string {
984 best, bestRank := "", 1<<30
985 for _, e := range entries {
986 if e.Type != "blob" {
987 continue
988 }
989 lower := strings.ToLower(e.Name)
990 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
991 continue
992 }
993 rank, ok := readmeRank[path.Ext(lower)]
994 if !ok {
995 rank = 10 // plaintext fallback
996 }
997 if rank < bestRank {
998 best, bestRank = e.Name, rank
999 }
1000 }
1001 return best
1002}
1003
1004// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1005// task lists) on top of CommonMark, with class-based fence highlighting
1006// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1007// dropped.
1008// Headings carry ids so a README or wiki section can be linked to, the
1009// way org headings already are (#132).
1010var markdown = goldmark.New(
1011 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1012 goldmark.WithExtensions(extension.GFM,
1013 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1014
1015// fenceHighlight renders one code block with chroma classes, for org and
1016// anything else outside goldmark. Unknown languages fall back to plain.
1017func fenceHighlight(source, lang string) string {
1018 lexer := lexers.Get(lang)
1019 if lexer == nil {
1020 lexer = lexers.Fallback
1021 }
1022 iterator, err := lexer.Tokenise(nil, source)
1023 if err != nil {
1024 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1025 }
1026 var buf bytes.Buffer
1027 f := html.New(html.WithClasses(true))
1028 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1029 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1030 }
1031 return buf.String()
1032}
1033
1034// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1035// goldmark's default renderer drops raw HTML, so this is safe as-is.
1036func mdHTML(raw string) template.HTML {
1037 if strings.TrimSpace(raw) == "" {
1038 return ""
1039 }
1040 var buf bytes.Buffer
1041 if markdown.Convert([]byte(raw), &buf) != nil {
1042 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1043 }
1044 return template.HTML(buf.String())
1045}
1046
1047// aboutHTML renders a profile's about text. It has no filename to
1048// dispatch on, so the stored format picks the extension; anything other
1049// than org is markdown.
1050func aboutHTML(p store.Profile) template.HTML {
1051 if strings.TrimSpace(p.About) == "" {
1052 return ""
1053 }
1054 name := "about.md"
1055 if p.AboutFormat == "org" {
1056 name = "about.org"
1057 }
1058 return renderReadme(name, []byte(p.About))
1059}
1060
1061// webResolver answers autolink lookups for one viewer. Cross-repo
1062// references to repositories the viewer cannot read stay plain text, per
1063// the enumeration rule: a link would confirm the repo exists.
1064type webResolver struct {
1065 s *Server
1066 viewer store.User
1067}
1068
1069func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1070 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1071 if err != nil {
1072 return ""
1073 }
1074 grant := ""
1075 if r.viewer.ID != 0 {
1076 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1077 }
1078 if !policy.CanRead(r.viewer, repo, grant) {
1079 return ""
1080 }
1081 if kind == '#' {
1082 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1083 return ""
1084 }
1085 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1086 }
1087 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1088 return ""
1089 }
1090 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1091}
1092
1093func (r webResolver) UserURL(name string) string {
1094 if _, err := r.s.st.UserByUsername(name); err == nil {
1095 return "/" + name
1096 }
1097 if _, err := r.s.st.OrgByName(name); err == nil {
1098 return "/" + name
1099 }
1100 return ""
1101}
1102
1103// ugcRenderer renders one user-authored body in the format it was written in.
1104// The format travels with the body: it is recorded when the text is written, so
1105// changing a preference later cannot re-interpret prose that already exists.
1106type ugcRenderer func(raw, format string) template.HTML
1107
1108// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1109// so a body stored before formats existed — and any row whose column defaulted —
1110// renders exactly as it did before.
1111//
1112// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1113// about text take, so it inherits that function's include guard and sanitising
1114// rather than growing a second org renderer to keep in step.
1115func ugcHTML(raw, format string) template.HTML {
1116 if format == "org" {
1117 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1118 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1119 })
1120 }
1121 return mdHTML(raw)
1122}
1123
1124// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1125// ugcHTML plus cross-reference and mention autolinking for this viewer.
1126func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1127 viewer := store.User{}
1128 if s.cfg.Web.Mode == "accounts" {
1129 viewer = s.viewer(r)
1130 }
1131 res := webResolver{s, viewer}
1132 return func(raw, format string) template.HTML {
1133 h := ugcHTML(raw, format)
1134 if h == "" {
1135 return h
1136 }
1137 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1138 }
1139}
1140
1141// renderedComment pairs a comment with its rendered body for templates.
1142type renderedComment struct {
1143 Author string
1144 CreatedAt string
1145 Kind string
1146 BodyHTML template.HTML
1147}
1148
1149func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1150 var out []renderedComment
1151 for _, c := range cs {
1152 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1153 }
1154 return out
1155}
1156
1157// ugcPolicy sanitizes rendered repo content before it enters the forge's
1158// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1159// output and repo-authored HTML are not. Chroma's highlighting classes
1160// must survive; the pattern admits only short token codes, not the site's
1161// own class names.
1162var ugcPolicy = func() *bluemonday.Policy {
1163 p := bluemonday.UGCPolicy()
1164 p.AllowAttrs("class").
1165 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1166 OnElements("span", "pre", "code", "div")
1167 return p
1168}()
1169
1170// renderReadme renders a README by extension: markdown, org-mode, and
1171// (sanitized) HTML richly; everything else as escaped plaintext.
1172// orgConfig is the go-org configuration for rendering untrusted org.
1173//
1174// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1175// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1176// wiki page, a profile — so both keywords are refused outright: the file is
1177// never opened and the keyword stays the inert text it is. There is no safe
1178// subset to allow instead. An absolute path skips go-org's relative-path join,
1179// a relative one resolves against the daemon's working directory, and a repo
1180// has no directory to scope to anyway because the content came from a git
1181// object rather than a checkout.
1182//
1183// The default logger writes parse warnings to stderr, which would let pushed
1184// content write to the server's log; discard them.
1185func orgConfig() *org.Configuration {
1186 c := org.New()
1187 c.ReadFile = func(string) ([]byte, error) {
1188 return nil, errOrgIncludeDisabled
1189 }
1190 c.Log = log.New(io.Discard, "", 0)
1191 return c
1192}
1193
1194var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1195
1196// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1197// of contents: a README or wiki page is a document and carries one, an issue
1198// comment is a remark and should not sprout one above two headings. `fallback`
1199// supplies the plaintext rendering used when the writer fails.
1200func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1201 c := orgConfig()
1202 if !contents {
1203 // DefaultSettings is a fresh map per org.New(), so this is local.
1204 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1205 }
1206 doc := c.Parse(bytes.NewReader(raw), name)
1207 writer := org.NewHTMLWriter()
1208 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1209 if inline {
1210 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1211 }
1212 return fenceHighlight(source, lang)
1213 }
1214 out, err := doc.Write(writer)
1215 if err != nil {
1216 return fallback()
1217 }
1218 return template.HTML(ugcPolicy.Sanitize(out))
1219}
1220
1221// headingTag matches an opening or closing h1..h5 tag, so a rendered
1222// document's headings can move down one level.
1223var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1224
1225// demoteHeadings moves every heading in a rendered document down one
1226// level: the page it sits on already has its h1 (the repository, the
1227// file, the wiki page), so a README's own h1 would be a second top-level
1228// heading in the outline (#133). Ids and anchors are untouched.
1229func demoteHeadings(h template.HTML) template.HTML {
1230 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1231 sub := headingTag.FindStringSubmatch(m)
1232 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1233 }))
1234}
1235
1236func renderReadme(name string, raw []byte) template.HTML {
1237 plain := func() template.HTML {
1238 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1239 }
1240 if gitutil.IsBinary(raw) {
1241 return ""
1242 }
1243 switch path.Ext(strings.ToLower(name)) {
1244 case ".md", ".markdown":
1245 var buf bytes.Buffer
1246 if markdown.Convert(raw, &buf) != nil {
1247 return plain()
1248 }
1249 return demoteHeadings(template.HTML(buf.String()))
1250 case ".org":
1251 return demoteHeadings(renderOrg(name, raw, true, plain))
1252 case ".html", ".htm":
1253 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1254 default:
1255 return plain()
1256 }
1257}
1258
1259type diffThread struct {
1260 ID int64
1261 Resolved string
1262 Stale bool
1263 // Pending marks a thread in the viewer's own unsubmitted review. Only
1264 // they are shown it, and the page says so, since it looks exactly
1265 // like a posted one otherwise.
1266 Pending bool
1267 CanResolve bool
1268 Comments []renderedComment
1269}
1270
1271// reviewRights decides which thread controls a viewer sees. mr resolve
1272// admits the thread author, the MR author, or anyone with write, so the
1273// page needs all three to render the button truthfully.
1274type reviewRights struct {
1275 Viewer string
1276 MRAuthor string
1277 Write bool
1278}
1279
1280func (r reviewRights) canResolve(threadAuthor string) bool {
1281 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1282}
1283
1284// attachThreads injects review threads under their anchored diff lines;
1285// threads whose anchor no longer appears (stale after force-push, or on a
1286// context line outside the current diff) are returned separately.
1287func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1288 type anchor struct {
1289 path string
1290 side string
1291 line int64
1292 }
1293 // Diff-line comments have no stored format yet, so they stay markdown.
1294 // They are the one user-authored body left without the choice; see #51.
1295 threads := map[int64]*diffThread{}
1296 anchors := map[int64]anchor{}
1297 var order []int64
1298 for _, cm := range comments {
1299 if cm.ReplyTo == 0 {
1300 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1301 Pending: cm.Pending,
1302 CanResolve: rights.canResolve(cm.Author),
1303 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1304 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1305 order = append(order, cm.ID)
1306 } else if th, ok := threads[cm.ReplyTo]; ok {
1307 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1308 }
1309 }
1310 placed := map[int64]bool{}
1311 for f := range files {
1312 lines := files[f].Lines
1313 for i := range lines {
1314 for _, id := range order {
1315 if placed[id] || threads[id].Stale {
1316 continue
1317 }
1318 a := anchors[id]
1319 if lines[i].Path != a.path {
1320 continue
1321 }
1322 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1323 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1324 lines[i].Threads = append(lines[i].Threads, *threads[id])
1325 files[f].Threads++
1326 files[f].Open = true
1327 placed[id] = true
1328 }
1329 }
1330 }
1331 }
1332 var unplaced []diffThread
1333 for _, id := range order {
1334 if !placed[id] {
1335 unplaced = append(unplaced, *threads[id])
1336 }
1337 }
1338 return files, unplaced
1339}
1340
1341// markCompose opens the new-thread form under one diff line. There is no
1342// JavaScript, so "comment on this line" is a plain GET carrying the
1343// anchor and the page renders the form where the reader asked for it.
1344func markCompose(files []diffFile, q url.Values) {
1345 path := q.Get("cpath")
1346 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1347 if path == "" || line < 1 {
1348 return
1349 }
1350 old := q.Get("cside") == "old"
1351 for f := range files {
1352 for i := range files[f].Lines {
1353 ln := &files[f].Lines[i]
1354 if ln.Path != path {
1355 continue
1356 }
1357 if (old && ln.Class == "del" && ln.OldLine == line) ||
1358 (!old && ln.Class != "del" && ln.NewLine == line) {
1359 ln.Compose = true
1360 files[f].Open = true
1361 return
1362 }
1363 }
1364 }
1365}
1366
1367type sigView struct {
1368 State string
1369 Signer string
1370 Fingerprint string
1371}
1372
1373func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1374 raw, err := gitutil.ReadCommit(dir, sha)
1375 if err != nil {
1376 return sigView{State: "unsigned"}, nil
1377 }
1378 parsed, err := sig.ParseCommit(raw)
1379 if err != nil {
1380 return sigView{State: "unsigned"}, nil
1381 }
1382 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1383 if err != nil {
1384 return sigView{State: "unsigned"}, parsed
1385 }
1386 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1387 if res.SignerUserID != 0 {
1388 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1389 v.Signer = u.Username
1390 }
1391 }
1392 return v, parsed
1393}
1394
1395func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1396 ref := r.PathValue("ref")
1397 p, ok := s.repoFor(w, r, ref)
1398 if !ok {
1399 return
1400 }
1401 p.Tab = "log"
1402 const pageSize = 50
1403 // ?path= filters to commits touching one file or directory.
1404 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1405 if filePath == "." {
1406 filePath = ""
1407 }
1408 var shas []string
1409 var err error
1410 if filePath != "" {
1411 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1412 } else {
1413 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1414 }
1415 if err != nil {
1416 s.notFound(w, r)
1417 return
1418 }
1419 next := ""
1420 if len(shas) > pageSize {
1421 next = shas[pageSize]
1422 shas = shas[:pageSize]
1423 }
1424 type row struct {
1425 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1426 Sig sigView
1427 Check string // combined status, "" when none ran
1428 }
1429 names := s.authorNames()
1430 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1431 var rows []row
1432 for _, sha := range shas {
1433 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1434 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1435 if parsed != nil {
1436 rw.Subject = parsed.Subject
1437 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1438 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1439 rw.AuthorEmail = parsed.AuthorEmail
1440 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1441 }
1442 rows = append(rows, rw)
1443 }
1444 s.render(w, "log.html", struct {
1445 repoPage
1446 Commits []row
1447 NextSHA string
1448 FilePath string
1449 }{p, rows, next, filePath})
1450}
1451
1452func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1453 p, ok := s.repoFor(w, r, "")
1454 if !ok {
1455 return
1456 }
1457 p.Tab = "log"
1458 sha := r.PathValue("sha")
1459 full, err := gitutil.ResolveRef(p.Dir, sha)
1460 if err != nil {
1461 s.notFound(w, r)
1462 return
1463 }
1464 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1465 if parsed == nil {
1466 s.notFound(w, r)
1467 return
1468 }
1469 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1470 files := parseDiff(patch)
1471 committerEmail := ""
1472 if parsed.CommitterEmail != parsed.AuthorEmail {
1473 committerEmail = parsed.CommitterEmail
1474 }
1475 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1476 commitNames := s.authorNames()
1477 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1478 msg := ""
1479 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1480 msg = string(parsed.Payload[i+2:])
1481 }
1482 s.render(w, "commit.html", struct {
1483 repoPage
1484 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1485 Parents []string
1486 Sig sigView
1487 Checks []store.CommitStatus
1488 DiffFiles []diffFile
1489 DiffTruncated bool
1490 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1491 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1492 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1493}
1494
1495// labelPalette provides default label chip colors: mid-tone hues that stay
1496// legible on light and dark backgrounds.
1497var labelPalette = []string{
1498 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1499 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1500}
1501
1502var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1503
1504// clampChip keeps a user-set label colour legible as text on both
1505// grounds. Contrast is defined on relative luminance, so that is what is
1506// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1507// and against the dark ground alike, and where the palette's own colours
1508// sit. The hue is kept; the channels are scaled in linear light (#120).
1509func clampChip(hex string) string {
1510 lin := func(c int64) float64 {
1511 v := float64(c) / 255
1512 if v <= 0.04045 {
1513 return v / 12.92
1514 }
1515 return math.Pow((v+0.055)/1.055, 2.4)
1516 }
1517 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1518 y := 0.2126*r + 0.7152*g + 0.0722*b
1519 const lo, hi = 0.12, 0.28
1520 if y >= lo && y <= hi {
1521 return strings.ToLower(hex)
1522 }
1523 target := hi
1524 if y < lo {
1525 target = lo
1526 }
1527 if y == 0 {
1528 r, g, b = target, target, target
1529 } else {
1530 k := target / y
1531 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1532 }
1533 enc := func(v float64) int {
1534 if v <= 0.0031308 {
1535 v *= 12.92
1536 } else {
1537 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1538 }
1539 return int(math.Round(v * 255))
1540 }
1541 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1542}
1543
1544func hexByte(s string) int64 {
1545 n, _ := strconv.ParseInt(s, 16, 32)
1546 return n
1547}
1548
1549// labelColors returns a complete label-name -> chip color map for a repo:
1550// the stored labels.color when it is a valid hex color, otherwise a
1551// stable default picked from the palette by name hash.
1552func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1553 stored, _ := s.st.LabelColors(repoID)
1554 out := make(map[string]template.CSS, len(stored))
1555 for name, color := range stored {
1556 if !hexColorPat.MatchString(color) {
1557 h := fnv.New32a()
1558 h.Write([]byte(name))
1559 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1560 }
1561 out[name] = template.CSS("--chip:" + clampChip(color))
1562 }
1563 return out
1564}
1565
1566// listPage is how many issues or merge requests a list page shows before
1567// it offers the older ones (#118). Keyset paging on the number, the same
1568// cursor the commands use, so every filter carries across pages.
1569const listPage = 50
1570
1571// olderLink is the current URL with before=<number> set.
1572func olderLink(r *http.Request, before int64) string {
1573 q := r.URL.Query()
1574 q.Set("before", strconv.FormatInt(before, 10))
1575 return "?" + q.Encode()
1576}
1577
1578func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1579 p, ok := s.repoFor(w, r, "")
1580 if !ok {
1581 return
1582 }
1583 p.Tab = "issues"
1584 state := r.URL.Query().Get("state")
1585 if state != "closed" && state != "all" {
1586 state = "open"
1587 }
1588 // The same filters the CLI's issue list takes, as query parameters;
1589 // label chips and author links point here.
1590 qv := r.URL.Query()
1591 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1592 Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1593 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1594 f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1595 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1596 if err != nil {
1597 http.Error(w, "internal error", http.StatusInternalServerError)
1598 return
1599 }
1600 older := ""
1601 if len(issues) > listPage {
1602 issues = issues[:listPage]
1603 older = olderLink(r, issues[len(issues)-1].Number)
1604 }
1605 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1606 for i := range issues {
1607 issues[i].Labels = labels[issues[i].ID]
1608 }
1609 }
1610 s.render(w, "issues.html", struct {
1611 repoPage
1612 State string
1613 Label string
1614 Query string
1615 Filters []listFilter
1616 Issues []store.Issue
1617 LabelColors map[string]template.CSS
1618 Older string
1619 }{p, state, f.Label, f.Search,
1620 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1621 issues, s.labelColors(p.Repo.ID), older})
1622}
1623
1624func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1625 p, ok := s.repoFor(w, r, "")
1626 if !ok {
1627 return
1628 }
1629 p.Tab = "issues"
1630 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1631 if err != nil {
1632 s.notFound(w, r)
1633 return
1634 }
1635 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1636 if err != nil {
1637 s.notFound(w, r)
1638 return
1639 }
1640 comments, err := s.st.ListIssueComments(iss.ID)
1641 if err != nil {
1642 http.Error(w, "internal error", http.StatusInternalServerError)
1643 return
1644 }
1645 md := s.ugcFor(r, p.Repo)
1646 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1647 s.render(w, "issue.html", struct {
1648 repoPage
1649 Issue store.Issue
1650 BodyHTML template.HTML
1651 Comments []renderedComment
1652 CanEdit bool
1653 CanWrite bool
1654 Milestones []store.Milestone
1655 Notice string
1656 LabelColors map[string]template.CSS
1657 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1658 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1659 milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1660}
1661
1662// canEditItem: the author or anyone with write access may edit.
1663// canWriteRepo reports whether the browser session may push to the repo,
1664// which is what gates the review and merge controls.
1665func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1666 if s.cfg.Web.Mode != "accounts" {
1667 return false
1668 }
1669 u := s.viewer(r)
1670 if u.ID == 0 {
1671 return false
1672 }
1673 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1674 return policy.CanWrite(u, repo, grant)
1675}
1676
1677func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1678 if s.cfg.Web.Mode != "accounts" {
1679 return false
1680 }
1681 u := s.viewer(r)
1682 if u.ID == 0 {
1683 return false
1684 }
1685 if u.Username == author {
1686 return true
1687 }
1688 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1689 return policy.CanWrite(u, repo, grant)
1690}
1691
1692func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1693 p, ok := s.repoFor(w, r, "")
1694 if !ok {
1695 return
1696 }
1697 p.Tab = "merge requests"
1698 state := r.URL.Query().Get("state")
1699 if state == "" {
1700 state = "open"
1701 }
1702 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1703 if !valid[state] {
1704 state = "open"
1705 }
1706 qv := r.URL.Query()
1707 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1708 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1709 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1710 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1711 if err != nil {
1712 http.Error(w, "internal error", http.StatusInternalServerError)
1713 return
1714 }
1715 older := ""
1716 if len(mrs) > listPage {
1717 mrs = mrs[:listPage]
1718 older = olderLink(r, mrs[len(mrs)-1].Number)
1719 }
1720 s.render(w, "mrs.html", struct {
1721 repoPage
1722 State string
1723 Query string
1724 Filters []listFilter
1725 MRs []store.MR
1726 Older string
1727 }{p, state, mf.Search,
1728 activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1729}
1730
1731func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1732 p, ok := s.repoFor(w, r, "")
1733 if !ok {
1734 return
1735 }
1736 p.Tab = "merge requests"
1737 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1738 if err != nil {
1739 s.notFound(w, r)
1740 return
1741 }
1742 m, err := s.st.MRByNumber(p.Repo.ID, n)
1743 if err != nil {
1744 s.notFound(w, r)
1745 return
1746 }
1747 comments, _ := s.st.ListMRComments(m.ID)
1748 reviews, _ := s.st.ListMRReviews(m.ID)
1749 // The same rule the merge gates apply, so the page cannot show an
1750 // approval the gate ignores (#147).
1751 reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1752 reviewRows := make([]reviewRow, 0, len(reviews))
1753 for _, r := range reviews {
1754 reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1755 }
1756 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1757 // The viewer sees their own unsubmitted review comments and nobody
1758 // else's.
1759 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1760
1761 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1762 var files []diffFile
1763 base := m.MergedBase
1764 if base == "" {
1765 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1766 base = b
1767 }
1768 }
1769 var diffTruncated bool
1770 if base != "" {
1771 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1772 files, diffTruncated = parseDiff(patch), truncated
1773 }
1774 }
1775 md := s.ugcFor(r, p.Repo)
1776 canWrite := s.canWriteRepo(r, p.Repo)
1777 var detachedThreads []diffThread
1778 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1779 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1780 if p.Viewer != "" {
1781 markCompose(files, r.URL.Query())
1782 }
1783 stat := statOf(files)
1784 // The commits this MR carries: base..head, the same range as the diff.
1785 type commitRow struct {
1786 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1787 Sig sigView
1788 }
1789 mrNames := s.authorNames()
1790 var commits []commitRow
1791 commitsTotal := 0
1792 if base != "" {
1793 const maxMRCommits = 100
1794 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1795 commitsTotal = len(shas)
1796 if len(shas) > maxMRCommits {
1797 shas = shas[:maxMRCommits]
1798 }
1799 for _, sha := range shas {
1800 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1801 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1802 if parsed != nil {
1803 cr.Subject = parsed.Subject
1804 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1805 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1806 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1807 }
1808 commits = append(commits, cr)
1809 }
1810 }
1811 // The diff is the reason most people open a merge request, so it gets
1812 // its own view rather than a fold at the foot of the conversation.
1813 // A query parameter keeps this working without JavaScript.
1814 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1815 // The revisions this merge request has had. A stale review is the
1816 // moment someone wants to know what moved, so the link to the
1817 // range-diff belongs next to it.
1818 revisions, _ := s.st.MRHeads(m.ID)
1819 branches, _ := gitutil.Refs(p.Dir, "heads")
1820 view := r.URL.Query().Get("view")
1821 if view != "commits" && view != "diff" {
1822 view = "conversation"
1823 }
1824 // The stack around an open merge request, for the header.
1825 var stackedOn *store.MR
1826 var stacked []store.MR
1827 if m.State == "open" {
1828 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1829 stackedOn = &parent
1830 }
1831 if m.SourceRepoID == p.Repo.ID {
1832 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1833 }
1834 }
1835 s.render(w, "mr.html", struct {
1836 repoPage
1837 MR store.MR
1838 View string
1839 BodyHTML template.HTML
1840 Checks []store.Check
1841 Combined string
1842 Comments []renderedComment
1843 Reviews []reviewRow
1844 DiffFiles []diffFile
1845 DiffTruncated bool
1846 Stat diffStat
1847 Commits []commitRow
1848 CommitsTotal int
1849 Branches []gitutil.Ref
1850 CanEdit bool
1851 CanWrite bool
1852 Unresolved int
1853 Revisions []store.MRHead
1854 Notice string
1855 DetachedThreads []diffThread
1856 StackedOn *store.MR
1857 Stacked []store.MR
1858 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1859 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1860 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1861}
1862
1863func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1864 p, ok := s.repoFor(w, r, "")
1865 if !ok {
1866 return
1867 }
1868 p.Tab = "refs"
1869 branches, _ := gitutil.Refs(p.Dir, "heads")
1870 tags, _ := gitutil.Refs(p.Dir, "tags")
1871 s.render(w, "refs.html", struct {
1872 repoPage
1873 Branches, Tags []gitutil.Ref
1874 }{p, branches, tags})
1875}
1876
1877func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1878 p, ok := s.repoFor(w, r, "")
1879 if !ok {
1880 return
1881 }
1882 file := r.PathValue("file")
1883 ref, ok := strings.CutSuffix(file, ".tar.gz")
1884 if !ok {
1885 s.notFound(w, r)
1886 return
1887 }
1888 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1889 s.notFound(w, r)
1890 return
1891 }
1892 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1893 w.Header().Set("Content-Type", "application/gzip")
1894 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1895 gitutil.Archive(p.Dir, ref, prefix, w)
1896}
1897
1898func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1899 return policy.CanAdmin(u, repo, grant)
1900}
1901
1902func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1903 return policy.CanRead(u, repo, grant)
1904}
1905
1906// reviewRow is a review with whether the merge gates count it, which
1907// depends on the reviewer's access and so is not a property of the
1908// review row itself.
1909type reviewRow struct {
1910 store.MRReview
1911 Counts bool
1912}